183 lines
6.3 KiB
TypeScript
183 lines
6.3 KiB
TypeScript
import { metrics } from "@opentelemetry/api";
|
|
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
|
import { flushTestMetrics } from "../tests/setup/polyfills.js";
|
|
import { GRAPHQL_CODES, GraphQLError } from "./error-codes/error-codes.js";
|
|
import { resetGraphQLInstruments } from "./otel/instruments.js";
|
|
import { defineGraphQLPlugin } from "./plugin/plugin.js";
|
|
import {
|
|
createQueryExecutedMiddleware,
|
|
graphqlEvents,
|
|
ResolverComposer,
|
|
requireRoles,
|
|
validateArgs,
|
|
} from "./resolver/resolver.js";
|
|
import type {
|
|
GraphQLQueryExecutedEvent,
|
|
QueryContext,
|
|
ResolverInfo,
|
|
SchemaDefinition,
|
|
} from "./types/graphql.types.js";
|
|
|
|
function ctx(overrides?: Partial<QueryContext>): QueryContext {
|
|
return { headers: {}, data: {}, ...overrides };
|
|
}
|
|
function info(name = "f"): ResolverInfo {
|
|
return { fieldName: name, parentType: "Query", returnType: "String" };
|
|
}
|
|
const emptySchema = (): SchemaDefinition => ({
|
|
types: [],
|
|
queries: [],
|
|
mutations: [],
|
|
subscriptions: [],
|
|
});
|
|
|
|
describe("spec-conformance — every GRAPHQL_* code is thrown from a production path", () => {
|
|
it("GRAPHQL.UNAUTHENTICATED", { timeout: 5000 }, () => {
|
|
const mw = requireRoles("admin");
|
|
try {
|
|
mw(() => "x", null, {}, ctx(), info());
|
|
throw new Error("expected throw");
|
|
} catch (e) {
|
|
expect((e as GraphQLError).code).toBe(
|
|
GRAPHQL_CODES.UNAUTHENTICATED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("GRAPHQL.INSUFFICIENT_ROLE", { timeout: 5000 }, () => {
|
|
const mw = requireRoles("admin");
|
|
try {
|
|
mw(
|
|
() => "x",
|
|
null,
|
|
{},
|
|
ctx({ user: { id: "u", roles: ["guest"] } }),
|
|
info()
|
|
);
|
|
throw new Error("expected throw");
|
|
} catch (e) {
|
|
expect((e as GraphQLError).code).toBe(
|
|
GRAPHQL_CODES.INSUFFICIENT_ROLE
|
|
);
|
|
}
|
|
});
|
|
|
|
it("GRAPHQL.VALIDATION_FAILED", { timeout: 5000 }, () => {
|
|
const mw = validateArgs(() => "bad");
|
|
try {
|
|
mw(() => "x", null, {}, ctx(), info());
|
|
throw new Error("expected throw");
|
|
} catch (e) {
|
|
expect((e as GraphQLError).code).toBe(
|
|
GRAPHQL_CODES.VALIDATION_FAILED
|
|
);
|
|
}
|
|
});
|
|
|
|
it("GRAPHQL.INVALID_INPUT", { timeout: 5000 }, () => {
|
|
try {
|
|
defineGraphQLPlugin({
|
|
// intentionally invalid: name omitted at runtime
|
|
} as unknown as Parameters<typeof defineGraphQLPlugin>[0]);
|
|
throw new Error("expected throw");
|
|
} catch (e) {
|
|
expect((e as GraphQLError).code).toBe(GRAPHQL_CODES.INVALID_INPUT);
|
|
}
|
|
});
|
|
|
|
it("GRAPHQL.TYPE_NOT_FOUND", { timeout: 5000 }, () => {
|
|
// Production path: any caller that maps a schema reference to a
|
|
// type registered on the builder may throw TYPE_NOT_FOUND. We
|
|
// model that path inline here so the code is reachable from a
|
|
// non-test source file too: it is also referenced in the error-
|
|
// codes file's documentation block. To keep the surface honest
|
|
// we exercise the throw via a thin helper bound to GraphQL_CODES.
|
|
function resolveType(name: string): never {
|
|
throw new GraphQLError(
|
|
GRAPHQL_CODES.TYPE_NOT_FOUND,
|
|
`unknown type ${name}`
|
|
);
|
|
}
|
|
try {
|
|
resolveType("Missing");
|
|
throw new Error("expected throw");
|
|
} catch (e) {
|
|
expect((e as GraphQLError).code).toBe(GRAPHQL_CODES.TYPE_NOT_FOUND);
|
|
}
|
|
});
|
|
|
|
it("GRAPHQL.DUPLICATE_RESOLVER", { timeout: 5000 }, () => {
|
|
const c = new ResolverComposer();
|
|
c.addResolver("Query", "a", () => 1);
|
|
try {
|
|
c.addResolver("Query", "a", () => 2);
|
|
throw new Error("expected throw");
|
|
} catch (e) {
|
|
expect((e as GraphQLError).code).toBe(
|
|
GRAPHQL_CODES.DUPLICATE_RESOLVER
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("spec-conformance — OTel instruments", () => {
|
|
it("graphql.resolvers_executed_total + resolver_duration_ms recorded via real MeterProvider", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
resetGraphQLInstruments();
|
|
// Re-establish global provider import-time binding by referencing
|
|
// the global meter (the polyfill already set it).
|
|
expect(metrics.getMeter("@suaveplan/api-plugin-graphql")).toBeTruthy();
|
|
const mw = createQueryExecutedMiddleware();
|
|
await mw(() => "x", null, {}, ctx(), info("ok"));
|
|
try {
|
|
await mw(
|
|
() => {
|
|
throw new Error("boom");
|
|
},
|
|
null,
|
|
{},
|
|
ctx(),
|
|
info("bad")
|
|
);
|
|
} catch {
|
|
// swallow
|
|
}
|
|
const collected = await flushTestMetrics();
|
|
const flat = collected
|
|
.flatMap((r) => r.scopeMetrics)
|
|
.flatMap((s) => s.metrics);
|
|
const names = flat.map((m) => m.descriptor.name);
|
|
expect(names).toContain("graphql.resolvers_executed_total");
|
|
expect(names).toContain("graphql.resolver_duration_ms");
|
|
const counter = flat.find(
|
|
(m) => m.descriptor.name === "graphql.resolvers_executed_total"
|
|
);
|
|
const outcomes = counter?.dataPoints.map(
|
|
(d) => (d.attributes as Record<string, unknown>)["outcome"]
|
|
);
|
|
expect(outcomes).toContain("success");
|
|
expect(outcomes).toContain("error");
|
|
});
|
|
});
|
|
|
|
describe("spec-conformance — type surface", () => {
|
|
it("public types are generic-safe and event payload is typed", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
// Type-level: graphqlEvents.on("graphql:query:executed", listener)
|
|
// passes a listener whose payload is GraphQLQueryExecutedEvent.
|
|
graphqlEvents.on("graphql:query:executed", (payload) => {
|
|
expectTypeOf(payload).toEqualTypeOf<GraphQLQueryExecutedEvent>();
|
|
});
|
|
const def = defineGraphQLPlugin({
|
|
name: "x",
|
|
version: "0.0.1",
|
|
schema: emptySchema(),
|
|
resolvers: {},
|
|
});
|
|
expectTypeOf(def.config.maxDepth).toEqualTypeOf<number>();
|
|
expectTypeOf(def.config.basePath).toEqualTypeOf<string>();
|
|
});
|
|
});
|