Files
HealthLog/eslint-plugins/healthlog/__tests__/queryKey-factory.test.mjs
T
Marc-André Bombeck 1f5115fbf3 fix(lint): see positional query keys and the app tree
Two holes, both proven with a probe that produced zero errors.

The rule matched the object properties `queryKey:` and `mutationKey:` and
nothing else, so the positional form was never seen —
`setQueryData(["bare"], 1)` seeds the same cache cell a `queryKey:` reads,
and `getQueryData` / `getQueryState` / the four defaults setters read and
write it the same way. Those seven methods are checked at their first
argument now, whether the call is bare, a member, or a computed member.

The enforced roots were components, hooks, query modules, and files
carrying their own "use client", which left src/app out. That is where the
server prefetch pages build the keys the client later reads, and they
carry no directive, so a server key diverging from the client's was
unguarded. src/app is a root now.

No violation surfaces on the current tree. The rule header records what it
still cannot see: a key assembled into a variable before the call site,
and the method-name match, which neither follows a project helper nor
knows an unrelated object with the same method name.
2026-09-03 01:46:29 +02:00

148 lines
5.4 KiB
JavaScript

import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import plugin from "../queryKey-factory.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const rule = plugin.rules["queryKey-factory"];
ruleTester.run("queryKey-factory", rule, {
valid: [
{
code: "useQuery({ queryKey: queryKeys.example(id) });",
filename: "/repo/src/components/example.tsx",
},
{
code: "const key = queryKeys.example(id); useQuery({ queryKey: key });",
filename: "/repo/src/hooks/use-example.ts",
},
{
code: 'export const example = () => ["example"];',
filename: "/repo/src/lib/query-keys/example.ts",
},
{
code: 'useQuery({ queryKey: ["fixture"] });',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
// A server module outside every guarded root stays unguarded — the roots
// are the scope decision, not an accident of this fixture.
{
code: 'useQuery({ queryKey: ["server-only-lib"] });',
filename: "/repo/src/lib/reports/build-report.ts",
},
// The positional cache API, used correctly: the key still comes from the
// factory, so the shape that poisons the cache never arises.
{
code: "queryClient.setQueryData(queryKeys.example(id), next);",
filename: "/repo/src/app/page.tsx",
},
{
code: "const key = queryKeys.example(id); queryClient.setQueryData(key, next);",
filename: "/repo/src/components/example.tsx",
},
// Tests seed caches with literal tuples on purpose; the factory directory
// constructs them by definition. Both stay exempt on the positional form
// exactly as they are on the object-property form.
{
code: 'client.setQueryData(["fixture"], 1);',
filename: "/repo/src/hooks/__tests__/use-example.test.ts",
},
{
code: 'client.setQueryData(["fixture"], 1);',
filename: "/repo/src/lib/query-keys/__fixtures__/seed.ts",
},
],
invalid: [
{
code: 'useQuery({ queryKey: ["unguarded-component"] });',
filename: "/repo/src/components/custom-metrics/example.tsx",
errors: [{ messageId: "bareArray" }],
},
{
code: 'useMutation({ mutationKey: ["unguarded-hook"] });',
filename: "/repo/src/hooks/use-example.ts",
errors: [{ messageId: "bareArray" }],
},
{
code: '"use client"; useQuery({ queryKey: ["client-app"] });',
filename: "/repo/src/app/previously-unguarded/page.tsx",
errors: [{ messageId: "bareArray" }],
},
{
code: '"use client"; useQuery({ queryKey: ["client-lib"] });',
filename: "/repo/src/lib/client-state.tsx",
errors: [{ messageId: "bareArray" }],
},
// src/app is where the server prefetch pages build the keys the client
// then reads. A key that differs from the client's is a cache miss at
// best and a poisoned cell at worst, and without `"use client"` on the
// page the rule used to skip the file entirely.
{
code: 'useQuery({ queryKey: ["server-prefetch"] });',
filename: "/repo/src/app/server-page.tsx",
errors: [{ messageId: "bareArray" }],
},
{
code: 'queryClient.prefetchQuery({ queryKey: ["dashboard"], queryFn: load });',
filename: "/repo/src/app/coach/page.tsx",
errors: [{ messageId: "bareArray" }],
},
// The positional form of the same mistake. `setQueryData` seeds a cache
// cell by key exactly as `queryKey:` reads one, so a bare array here is
// the same defect written differently.
{
code: 'queryClient.setQueryData(["bare", "positional"], 1);',
filename: "/repo/src/app/coach/page.tsx",
errors: [{ messageId: "positionalArray" }],
},
{
code: 'const cached = queryClient.getQueryData(["bare"]);',
filename: "/repo/src/hooks/use-example.ts",
errors: [{ messageId: "positionalArray" }],
},
{
code: 'queryClient.getQueryState(["bare"]);',
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "positionalArray" }],
},
{
code: 'queryClient.setQueryDefaults(["bare"], { staleTime: 0 });',
filename: "/repo/src/lib/queries/use-example.ts",
errors: [{ messageId: "positionalArray" }],
},
{
code: 'queryClient.setMutationDefaults(["bare"], { retry: 0 });',
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "positionalArray" }],
},
// Destructured off the client, and reached through a computed member —
// both spell the same call and both must be seen.
{
code: 'const { setQueryData } = useQueryClient(); setQueryData(["bare"], 1);',
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "positionalArray" }],
},
{
code: 'queryClient["setQueryData"](["bare"], 1);',
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "positionalArray" }],
},
// Both holes in one file, which is the probe that found them.
{
code: 'queryClient.setQueryData(["bare", "positional"], 1); useQuery({ queryKey: ["bare", "object-property"] });',
filename: "/repo/src/app/coach/page.tsx",
errors: [{ messageId: "positionalArray" }, { messageId: "bareArray" }],
},
],
});