fix(core-testing): handle batched paths + transformed errors in mock tRPC

httpBatchLink joins same-tick calls into one request whose path is a
comma-separated list of procedure paths; the stub matched the joined
string against a single mock key, so any batched pair failed with 'No
mock for a.one,a.two'. It now answers one element per procedure, in
order. Error bodies are also superjson-serialized — raw error JSON made
the client throw 'Unable to transform response' instead of surfacing
the intended error. Regression tests added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:32:03 +02:00
parent 8476712620
commit 0234e18425
2 changed files with 65 additions and 6 deletions

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { initTRPC } from "@trpc/server";
import { z } from "zod";
import { createMockTrpcClient } from "@/react/mock-trpc";
const t = initTRPC.create();
const _router = t.router({
blog: t.router({
articles: t.procedure.output(z.string()).query(() => "articles"),
articleBySlug: t.procedure.output(z.string()).query(() => "article"),
}),
});
type Router = typeof _router;
describe("createMockTrpcClient", () => {
it("resolves a single call from its mock", async () => {
const client = createMockTrpcClient<Router>({
"blog.articles": "mock-articles",
});
await expect(client.blog.articles.query()).resolves.toBe("mock-articles");
});
it("resolves two same-tick (batched) calls, one result per procedure", async () => {
const client = createMockTrpcClient<Router>({
"blog.articles": "mock-articles",
"blog.articleBySlug": "mock-article",
});
// httpBatchLink joins these into ONE request with a comma-separated
// procedure path — the stub must answer per procedure, in order.
const [articles, article] = await Promise.all([
client.blog.articles.query(),
client.blog.articleBySlug.query(),
]);
expect(articles).toBe("mock-articles");
expect(article).toBe("mock-article");
});
it("rejects a call whose procedure has no mock", async () => {
const client = createMockTrpcClient<Router>({});
await expect(client.blog.articles.query()).rejects.toThrow(
/No mock for blog\.articles/,
);
});
});

View File

@@ -10,12 +10,27 @@ export function createMockTrpcClient<TRouter extends AnyTRPCRouter>(
) {
const fetchStub: typeof fetch = async (input) => {
const url = typeof input === "string" ? input : (input as Request).url;
const path = new URL(url, "http://mock").pathname.replace(/^\/api\/trpc\//, "");
const result = mocks[path];
if (result === undefined) {
return new Response(JSON.stringify([{ error: { code: -32603, message: `No mock for ${path}` } }]), { status: 200 });
}
return new Response(JSON.stringify([{ result: { data: superjson.serialize(result) } }]), { status: 200 });
const path = new URL(url, "http://mock").pathname.replace(
/^\/api\/trpc\//,
"",
);
// httpBatchLink joins same-tick calls into one request whose path is the
// comma-separated list of procedure paths; respond with one element per
// call, in order.
const paths = path.split(",");
const body = paths.map((p) =>
Object.hasOwn(mocks, p)
? { result: { data: superjson.serialize(mocks[p]) } }
: {
// Error payloads pass through the transformer too — serialize
// them or the client throws "Unable to transform response".
error: superjson.serialize({
code: -32603,
message: `No mock for ${p}`,
}),
},
);
return new Response(JSON.stringify(body), { status: 200 });
};
// httpBatchLink<TRouter> requires a conditional TransformerOptions<TRouter> that