152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
import { Router } from "@suaveplan/api-router";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
createGraphiqlUi,
|
|
GRAPHQL_UI_GRAPHIQL_CODES,
|
|
type GraphiqlRouteHandler,
|
|
type RouterResponseValue,
|
|
} from "../../src/index.js";
|
|
|
|
/**
|
|
* End-to-end integration test.
|
|
*
|
|
* Wires the GraphiQL UI plugin into an in-memory `Router` alongside a
|
|
* minimal POST `/graphql` handler that echoes a canned response, then
|
|
* exercises the full request flow:
|
|
* 1. GET `/graphql/ui` -> HTML shell (status 200, CSP header, hashed
|
|
* asset references embedded).
|
|
* 2. GET `/graphql/ui/assets/<hashed-name>` -> bundled JS / CSS
|
|
* payload (status 200, immutable cache, no CDN refs).
|
|
* 3. POST `/graphql` -> JSON response (the explorer would issue this
|
|
* via `fetch` once the user clicks "Run"; here we drive it
|
|
* directly so the integration scope stays in-process).
|
|
*
|
|
* The umbrella Stage-7 plan calls for a Playwright-driven browser test
|
|
* once `api-graphql-yoga` is wired. This test stands in until that
|
|
* package lands — it pins every contract this plugin is responsible
|
|
* for and stays meaningful without a real GraphQL engine.
|
|
*/
|
|
|
|
type ProtocolHandler = (request: {
|
|
readonly method: string;
|
|
readonly path: string;
|
|
readonly body?: string;
|
|
}) => RouterResponseValue;
|
|
|
|
function dispatch(
|
|
router: Router,
|
|
method: "GET" | "POST",
|
|
path: string,
|
|
body?: string
|
|
): RouterResponseValue {
|
|
const match = router.match(method, path);
|
|
if (match === null) throw new Error(`no match for ${method} ${path}`);
|
|
const handler = match.handler as unknown as
|
|
| GraphiqlRouteHandler
|
|
| ProtocolHandler;
|
|
return handler({ method: match.method, path, body });
|
|
}
|
|
|
|
describe("api-graphql-ui-graphiql — integration", () => {
|
|
it("boots end-to-end: shell loads, asset serves, query round-trips", {
|
|
timeout: 30000,
|
|
}, () => {
|
|
const router = new Router({ plugin: "host" });
|
|
|
|
// Minimal /graphql endpoint — JSON in, JSON out.
|
|
const protocolHandler: ProtocolHandler = (req) => {
|
|
const parsed: { query?: string } = req.body
|
|
? (JSON.parse(req.body) as { query?: string })
|
|
: {};
|
|
return {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
data: { echo: parsed.query ?? null },
|
|
}),
|
|
};
|
|
};
|
|
router.post(
|
|
"/graphql",
|
|
protocolHandler as unknown as Parameters<Router["post"]>[1]
|
|
);
|
|
|
|
createGraphiqlUi({}).register(router);
|
|
|
|
// 1. Shell loads at /graphql/ui.
|
|
const shell = dispatch(router, "GET", "/graphql/ui");
|
|
expect(shell.status).toBe(200);
|
|
expect(shell.headers["Content-Security-Policy"]).toContain(
|
|
"default-src 'self'"
|
|
);
|
|
expect(shell.body).toContain("<!DOCTYPE html>");
|
|
// No external host references.
|
|
expect(shell.body).not.toMatch(/https?:\/\//);
|
|
|
|
// Extract the JS asset filename from the shell.
|
|
const jsMatch = shell.body.match(
|
|
/\/graphql\/ui\/assets\/(graphiql\.[0-9a-f]{8}\.js)/
|
|
);
|
|
expect(jsMatch).not.toBeNull();
|
|
const jsFilename = jsMatch?.[1] ?? "";
|
|
expect(jsFilename.length).toBeGreaterThan(0);
|
|
|
|
// 2. Asset round-trips.
|
|
const asset = dispatch(
|
|
router,
|
|
"GET",
|
|
`/graphql/ui/assets/${jsFilename}`
|
|
);
|
|
expect(asset.status).toBe(200);
|
|
expect(asset.headers["Content-Type"]).toContain(
|
|
"application/javascript"
|
|
);
|
|
expect(asset.headers["Cache-Control"]).toBe(
|
|
"public, max-age=31536000, immutable"
|
|
);
|
|
expect(asset.body.length).toBeGreaterThan(0);
|
|
|
|
// 3. Query round-trips through the /graphql protocol endpoint
|
|
// — exactly what the in-browser explorer does on "Run".
|
|
const protocol = dispatch(
|
|
router,
|
|
"POST",
|
|
"/graphql",
|
|
JSON.stringify({ query: "{ ping }" })
|
|
);
|
|
expect(protocol.status).toBe(200);
|
|
const decoded = JSON.parse(protocol.body) as {
|
|
data?: { echo?: string };
|
|
};
|
|
expect(decoded.data?.echo).toBe("{ ping }");
|
|
});
|
|
|
|
it("stale-hash asset names resolve during a rolling deploy", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const router = new Router({ plugin: "host" });
|
|
createGraphiqlUi({}).register(router);
|
|
const stale = dispatch(
|
|
router,
|
|
"GET",
|
|
"/graphql/ui/assets/graphiql.deadbeef.js"
|
|
);
|
|
expect(stale.status).toBe(200);
|
|
expect(stale.body.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("asset endpoint rejects unknown filenames with ASSET_MISSING", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const router = new Router({ plugin: "host" });
|
|
createGraphiqlUi({}).register(router);
|
|
try {
|
|
dispatch(router, "GET", "/graphql/ui/assets/unknown.deadbeef.png");
|
|
throw new Error("expected throw");
|
|
} catch (err) {
|
|
const code = (err as { code?: string }).code;
|
|
expect(code).toBe(GRAPHQL_UI_GRAPHIQL_CODES.ASSET_MISSING);
|
|
}
|
|
});
|
|
});
|