rtk-query-synthesizer.test.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. /**
  2. * RTK Query generated-hook → endpoint synthesizer.
  3. *
  4. * RTK Query's `createApi({ endpoints })` defines endpoints as object-literal
  5. * properties (`getX: build.query(...)`) and generates one `useGetXQuery` /
  6. * `useUpdateYMutation` hook per endpoint, exported via a `const {…} = api`
  7. * destructuring. Neither the endpoint nor the generated hook is otherwise a node,
  8. * so a `component → useGetXQuery → getX → queryFn` flow has nothing to connect to.
  9. *
  10. * This validates the two halves: extraction mints a function node for each
  11. * endpoint (named by its key, both the `build => ({...})` arrow form and the
  12. * `endpoints(build){ return {...} }` method-shorthand form) and for each generated
  13. * hook binding; then the synthesizer bridges hook→endpoint by the naming
  14. * convention (incl. the `useLazyGetXQuery` variant → the same endpoint). Precision
  15. * is gated to genuinely-generated hooks: a hand-written `use*Query` arrow is never
  16. * bridged, and no edge ever crosses files.
  17. */
  18. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  19. import * as fs from 'node:fs';
  20. import * as path from 'node:path';
  21. import * as os from 'node:os';
  22. import { CodeGraph } from '../src';
  23. describe('rtk-query synthesizer', () => {
  24. let dir: string;
  25. beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rtk-query-')); });
  26. afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
  27. it('extracts endpoints + generated hooks and bridges hook→endpoint (arrow + method + lazy + factory forms)', async () => {
  28. // Arrow form (shapeshift-style): `endpoints: build => ({...})`, `queryFn: () => {}`.
  29. fs.writeFileSync(
  30. path.join(dir, 'fiatRampApi.ts'),
  31. `import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
  32. import { fetchRamps } from './ramps';
  33. export const fiatRampApi = createApi({
  34. reducerPath: 'fiatRampApi',
  35. baseQuery: fetchBaseQuery({ baseUrl: '/' }),
  36. endpoints: build => ({
  37. getFiatRamps: build.query({
  38. queryFn: async () => {
  39. const data = await fetchRamps();
  40. return { data };
  41. },
  42. }),
  43. placeOrder: build.mutation({
  44. query: body => ({ url: 'order', method: 'POST', body }),
  45. }),
  46. }),
  47. });
  48. export const { useGetFiatRampsQuery, usePlaceOrderMutation, useLazyGetFiatRampsQuery } = fiatRampApi;
  49. `
  50. );
  51. // Method-shorthand form (basetool-style): `endpoints(builder){ return {...} }`,
  52. // `query(){}` method handler, plus a factory-handler endpoint (no fn literal).
  53. fs.writeFileSync(
  54. path.join(dir, 'dashApi.ts'),
  55. `import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
  56. import { makeCheckFn } from './factory';
  57. export const dashApi = createApi({
  58. reducerPath: 'dash',
  59. baseQuery: fetchBaseQuery({ baseUrl: '/' }),
  60. endpoints(builder) {
  61. return {
  62. getDashboards: builder.query({
  63. query() {
  64. return '/dashboards';
  65. },
  66. }),
  67. checkConnection: builder.mutation({
  68. queryFn: makeCheckFn('/check'),
  69. }),
  70. };
  71. },
  72. });
  73. export const { useGetDashboardsQuery, useCheckConnectionMutation } = dashApi;
  74. `
  75. );
  76. // Components consuming the generated hooks.
  77. fs.writeFileSync(
  78. path.join(dir, 'Views.tsx'),
  79. `import { useGetFiatRampsQuery, useLazyGetFiatRampsQuery } from './fiatRampApi';
  80. import { useGetDashboardsQuery } from './dashApi';
  81. export function FiatForm() {
  82. const { data } = useGetFiatRampsQuery();
  83. return data;
  84. }
  85. export function DashList() {
  86. const { data } = useGetDashboardsQuery();
  87. return data;
  88. }
  89. export function LazyForm() {
  90. const [load] = useLazyGetFiatRampsQuery();
  91. return load;
  92. }
  93. `
  94. );
  95. const cg = await CodeGraph.init(dir, { silent: true });
  96. await cg.indexAll();
  97. const db = (cg as any).db.db;
  98. // Endpoints are extracted as function nodes named by their key.
  99. const endpoints = db
  100. .prepare(`SELECT name, kind FROM nodes WHERE name IN ('getFiatRamps','placeOrder','getDashboards','checkConnection')`)
  101. .all();
  102. expect(endpoints.length).toBe(4);
  103. expect(endpoints.every((n: any) => n.kind === 'function')).toBe(true);
  104. // Generated hooks are extracted as function nodes carrying the sentinel.
  105. const hooks = db
  106. .prepare(`SELECT name FROM nodes WHERE signature = '= RTK Query generated hook' ORDER BY name`)
  107. .all()
  108. .map((r: any) => r.name);
  109. expect(hooks).toEqual([
  110. 'useCheckConnectionMutation',
  111. 'useGetDashboardsQuery',
  112. 'useGetFiatRampsQuery',
  113. 'useLazyGetFiatRampsQuery',
  114. 'usePlaceOrderMutation',
  115. ]);
  116. // hook → endpoint synth edges, including the Lazy variant mapping to the same endpoint.
  117. const synth = db
  118. .prepare(
  119. `SELECT s.name source, t.name target, s.file_path sf, t.file_path tf
  120. FROM edges e JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
  121. WHERE json_extract(e.metadata,'$.synthesizedBy') = 'rtk-query'`
  122. )
  123. .all();
  124. const pairs = synth.map((r: any) => `${r.source}->${r.target}`).sort();
  125. expect(pairs).toEqual([
  126. 'useCheckConnectionMutation->checkConnection',
  127. 'useGetDashboardsQuery->getDashboards',
  128. 'useGetFiatRampsQuery->getFiatRamps',
  129. 'useLazyGetFiatRampsQuery->getFiatRamps',
  130. 'usePlaceOrderMutation->placeOrder',
  131. ]);
  132. // Every synth edge stays within one file (RTK colocates api + hooks).
  133. expect(synth.every((r: any) => r.sf === r.tf)).toBe(true);
  134. // The component reaches the hook (normal import/call resolution), so the full
  135. // `component → hook → endpoint` chain is connected.
  136. const compToHook = db
  137. .prepare(
  138. `SELECT s.name source, t.name target FROM edges e
  139. JOIN nodes s ON s.id = e.source JOIN nodes t ON t.id = e.target
  140. WHERE s.name = 'FiatForm' AND t.name = 'useGetFiatRampsQuery' AND e.kind = 'calls'`
  141. )
  142. .all();
  143. expect(compToHook.length).toBeGreaterThan(0);
  144. cg.close?.();
  145. });
  146. it('does not bridge a hand-written use*Query hook (no createApi, no sentinel) — 0 synth edges', async () => {
  147. // A real custom hook of the same name shape, plus a same-file `getThing`
  148. // function it could spuriously map to. Without the generated-hook sentinel +
  149. // createApi destructuring, the synthesizer must produce nothing.
  150. fs.writeFileSync(
  151. path.join(dir, 'useGetThingQuery.ts'),
  152. `export function getThing() { return 42; }
  153. export const useGetThingQuery = () => {
  154. return getThing();
  155. };
  156. `
  157. );
  158. fs.writeFileSync(
  159. path.join(dir, 'Thing.tsx'),
  160. `import { useGetThingQuery } from './useGetThingQuery';
  161. export function Thing() {
  162. return useGetThingQuery();
  163. }
  164. `
  165. );
  166. const cg = await CodeGraph.init(dir, { silent: true });
  167. await cg.indexAll();
  168. const db = (cg as any).db.db;
  169. const synth = db
  170. .prepare(`SELECT count(*) c FROM edges WHERE json_extract(metadata,'$.synthesizedBy') = 'rtk-query'`)
  171. .get();
  172. expect(synth.c).toBe(0);
  173. // The hand-written hook keeps its real body (not a sentinel binding).
  174. const sentinel = db
  175. .prepare(`SELECT count(*) c FROM nodes WHERE signature = '= RTK Query generated hook'`)
  176. .get();
  177. expect(sentinel.c).toBe(0);
  178. cg.close?.();
  179. });
  180. });