@codewithagents/openapi-react-query 3.5.1 → 3.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/plugins/hooks.js +7 -7
- package/dist/plugins/hooks.js.map +1 -1
- package/package.json +5 -2
- package/CHANGELOG.md +0 -161
- package/CLAUDE.md +0 -30
- package/__fixtures__/specs/task-hooks.json +0 -170
- package/src/__tests__/__snapshots__/hooks.test.ts.snap +0 -457
- package/src/__tests__/compat-matrix.test.ts +0 -25
- package/src/__tests__/config.test.ts +0 -291
- package/src/__tests__/hooks.property.test.ts +0 -310
- package/src/__tests__/hooks.test.ts +0 -1845
- package/src/__tests__/prettier-output.test.ts +0 -45
- package/src/__tests__/test-utils.test.ts +0 -69
- package/src/cli.ts +0 -27
- package/src/config.ts +0 -171
- package/src/generator.ts +0 -50
- package/src/plugins/hooks.ts +0 -823
- package/src/plugins/test-utils.ts +0 -77
- package/stryker.config.json +0 -19
- package/tsconfig.build.json +0 -10
- package/tsconfig.json +0 -9
- package/vitest.config.ts +0 -12
|
@@ -1,1845 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import type { OpenAPIV3_1 } from 'openapi-types'
|
|
5
|
-
import { generateHooks } from '../plugins/hooks.js'
|
|
6
|
-
|
|
7
|
-
// Load fixture directly (no parseSpec — avoids needing openapi-gen dist built)
|
|
8
|
-
const fixturePath = join(import.meta.dirname, '../../__fixtures__/specs/task-hooks.json')
|
|
9
|
-
const spec = JSON.parse(readFileSync(fixturePath, 'utf-8')) as OpenAPIV3_1.Document
|
|
10
|
-
|
|
11
|
-
const { filename, content } = generateHooks(spec, { staleTime: 30_000, gcTime: 300_000 })
|
|
12
|
-
|
|
13
|
-
describe('generateHooks — task-hooks.json fixture', () => {
|
|
14
|
-
it('filename is hooks.ts', () => {
|
|
15
|
-
expect(filename).toBe('hooks.ts')
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it('contains auto-generated header comment', () => {
|
|
19
|
-
expect(content).toContain(
|
|
20
|
-
'// This file is auto-generated by @codewithagents/openapi-react-query — do not edit'
|
|
21
|
-
)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('imports useQuery and useMutation from @tanstack/react-query', () => {
|
|
25
|
-
expect(content).toContain("from '@tanstack/react-query'")
|
|
26
|
-
expect(content).toContain('useQuery')
|
|
27
|
-
expect(content).toContain('useMutation')
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('imports all client functions and ApiError from ./client.js', () => {
|
|
31
|
-
expect(content).toContain("from './client.js'")
|
|
32
|
-
expect(content).toContain('listTasks')
|
|
33
|
-
expect(content).toContain('getTask')
|
|
34
|
-
expect(content).toContain('createTask')
|
|
35
|
-
expect(content).toContain('updateTask')
|
|
36
|
-
expect(content).toContain('deleteTask')
|
|
37
|
-
expect(content).toContain('type ApiError')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('generates taskKeys factory with all(), list(), and detail() entries', () => {
|
|
41
|
-
expect(content).toContain('export const taskKeys')
|
|
42
|
-
expect(content).toContain("all: () => ['tasks'] as const")
|
|
43
|
-
expect(content).toContain('list:')
|
|
44
|
-
expect(content).toContain('detail:')
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it('useListTasks uses taskKeys.list(params) as queryKey', () => {
|
|
48
|
-
expect(content).toContain('useListTasks')
|
|
49
|
-
expect(content).toContain('queryKey: taskKeys.list(params)')
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it('useListTasks has staleTime: 30_000', () => {
|
|
53
|
-
expect(content).toContain('staleTime: 30000,')
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('useListTasks has gcTime: 300_000', () => {
|
|
57
|
-
expect(content).toContain('gcTime: 300000,')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('useGetTask accepts id as positional arg and uses taskKeys.detail(id!)', () => {
|
|
61
|
-
expect(content).toContain('useGetTask')
|
|
62
|
-
expect(content).toContain('id: string | undefined | null')
|
|
63
|
-
expect(content).toContain('queryKey: taskKeys.detail(id!)')
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
it('useCreateTask variables type is Parameters<typeof createTask>[0]', () => {
|
|
67
|
-
expect(content).toContain('useCreateTask')
|
|
68
|
-
expect(content).toContain('Parameters<typeof createTask>[0]')
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('useUpdateTask variables type has { id: string; body: Parameters<typeof updateTask>[1] }', () => {
|
|
72
|
-
expect(content).toContain('useUpdateTask')
|
|
73
|
-
expect(content).toContain('{ id: string; body: Parameters<typeof updateTask>[1] }')
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
it('useUpdateTask mutationFn destructures { id, body }', () => {
|
|
77
|
-
expect(content).toContain('({ id, body }) => updateTask(id, body)')
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('useDeleteTask variables type is string', () => {
|
|
81
|
-
// deleteTask has one path param and no body → variables type is string
|
|
82
|
-
expect(content).toContain('useDeleteTask')
|
|
83
|
-
// The mutation signature should include 'string' as variables type for deleteTask
|
|
84
|
-
// We check that the mutationFn destructures id directly
|
|
85
|
-
expect(content).toContain('(id) => deleteTask(id)')
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('useDeleteTask mutationFn is (id) => deleteTask(id)', () => {
|
|
89
|
-
expect(content).toContain('mutationFn: (id) => deleteTask(id)')
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('staleTime and gcTime are overridable via options spread', () => {
|
|
93
|
-
// ...options must appear AFTER staleTime and gcTime in the useQuery call
|
|
94
|
-
const staleTimeIdx = content.indexOf('staleTime: 30000,')
|
|
95
|
-
const gcTimeIdx = content.indexOf('gcTime: 300000,')
|
|
96
|
-
const spreadIdx = content.indexOf('...options,', gcTimeIdx)
|
|
97
|
-
expect(staleTimeIdx).toBeGreaterThan(-1)
|
|
98
|
-
expect(gcTimeIdx).toBeGreaterThan(staleTimeIdx)
|
|
99
|
-
expect(spreadIdx).toBeGreaterThan(gcTimeIdx)
|
|
100
|
-
})
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
// ── Feature #61: Auto-disable detail hooks when path param is nullish ─────────
|
|
104
|
-
|
|
105
|
-
describe('generateHooks — Feature #61: nullish path params auto-disable', () => {
|
|
106
|
-
it('useGetTask signature contains string | undefined | null for id', () => {
|
|
107
|
-
expect(content).toContain('id: string | undefined | null')
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('useGetTask generated hook contains enabled: id != null guard', () => {
|
|
111
|
-
expect(content).toContain('enabled: id != null && (options?.enabled ?? true)')
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
it('enabled guard comes before ...options spread', () => {
|
|
115
|
-
const enabledIdx = content.indexOf('enabled: id != null')
|
|
116
|
-
const spreadIdx = content.indexOf('...options,', enabledIdx)
|
|
117
|
-
expect(enabledIdx).toBeGreaterThan(-1)
|
|
118
|
-
expect(spreadIdx).toBeGreaterThan(enabledIdx)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('hooks without path params do NOT have an enabled guard', () => {
|
|
122
|
-
// useListTasks has no path params — should not generate an enabled guard
|
|
123
|
-
const listHookStart = content.indexOf('export function useListTasks')
|
|
124
|
-
const listHookEnd = content.indexOf('\n}', listHookStart) + 2
|
|
125
|
-
const listHookContent = content.slice(listHookStart, listHookEnd)
|
|
126
|
-
expect(listHookContent).not.toContain('enabled:')
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it('multiple path params generate AND-chained enabled guard', () => {
|
|
130
|
-
const specMultiParam: OpenAPIV3_1.Document = {
|
|
131
|
-
openapi: '3.1.0',
|
|
132
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
133
|
-
paths: {
|
|
134
|
-
'/items/{projectId}/{id}': {
|
|
135
|
-
get: {
|
|
136
|
-
operationId: 'getItem',
|
|
137
|
-
parameters: [
|
|
138
|
-
{ name: 'projectId', in: 'path', required: true, schema: { type: 'string' } },
|
|
139
|
-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
|
140
|
-
],
|
|
141
|
-
responses: { '200': { description: 'ok' } },
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
}
|
|
146
|
-
const { content: c } = generateHooks(specMultiParam, { staleTime: 0, gcTime: 0 })
|
|
147
|
-
expect(c).toContain('projectId: string | undefined | null')
|
|
148
|
-
expect(c).toContain('id: string | undefined | null')
|
|
149
|
-
expect(c).toContain('enabled: projectId != null && id != null && (options?.enabled ?? true)')
|
|
150
|
-
})
|
|
151
|
-
})
|
|
152
|
-
|
|
153
|
-
// ── Bug #1: Params optionality ──────────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
describe('generateHooks — Bug #52: params optionality based on required query params', () => {
|
|
156
|
-
// Spec with a required query param
|
|
157
|
-
const specWithRequiredQueryParam: OpenAPIV3_1.Document = {
|
|
158
|
-
openapi: '3.1.0',
|
|
159
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
160
|
-
paths: {
|
|
161
|
-
'/templates': {
|
|
162
|
-
get: {
|
|
163
|
-
operationId: 'listTemplates',
|
|
164
|
-
parameters: [
|
|
165
|
-
{ name: 'platform', in: 'query', required: true, schema: { type: 'string' } },
|
|
166
|
-
{ name: 'page', in: 'query', schema: { type: 'integer' } },
|
|
167
|
-
],
|
|
168
|
-
responses: { '200': { description: 'ok' } },
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Spec with only optional query params
|
|
175
|
-
const specWithOptionalQueryParams: OpenAPIV3_1.Document = {
|
|
176
|
-
openapi: '3.1.0',
|
|
177
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
178
|
-
paths: {
|
|
179
|
-
'/templates': {
|
|
180
|
-
get: {
|
|
181
|
-
operationId: 'listTemplates',
|
|
182
|
-
parameters: [
|
|
183
|
-
{ name: 'page', in: 'query', schema: { type: 'integer' } },
|
|
184
|
-
{ name: 'size', in: 'query', schema: { type: 'integer' } },
|
|
185
|
-
],
|
|
186
|
-
responses: { '200': { description: 'ok' } },
|
|
187
|
-
},
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
it('hook marks params as required when any query param has required: true', () => {
|
|
193
|
-
const { content } = generateHooks(specWithRequiredQueryParam, { staleTime: 0, gcTime: 0 })
|
|
194
|
-
// params should NOT be optional (no ?)
|
|
195
|
-
expect(content).toContain('params: Parameters<typeof listTemplates>')
|
|
196
|
-
expect(content).not.toContain('params?: Parameters<typeof listTemplates>')
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
it('key factory marks params as required when any query param has required: true', () => {
|
|
200
|
-
const { content } = generateHooks(specWithRequiredQueryParam, { staleTime: 0, gcTime: 0 })
|
|
201
|
-
// key factory list entry should have required params
|
|
202
|
-
expect(content).toContain('params: Parameters<typeof listTemplates>[0]')
|
|
203
|
-
expect(content).not.toContain('params?: Parameters<typeof listTemplates>[0]')
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
it('hook marks params as optional when all query params are optional', () => {
|
|
207
|
-
const { content } = generateHooks(specWithOptionalQueryParams, { staleTime: 0, gcTime: 0 })
|
|
208
|
-
expect(content).toContain('params?: Parameters<typeof listTemplates>')
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it('key factory marks params as optional when all query params are optional', () => {
|
|
212
|
-
const { content } = generateHooks(specWithOptionalQueryParams, { staleTime: 0, gcTime: 0 })
|
|
213
|
-
expect(content).toContain('params?: Parameters<typeof listTemplates>[0]')
|
|
214
|
-
})
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
// ── Bug #2: Key factory missing query params for multi-arg endpoints ────────────
|
|
218
|
-
|
|
219
|
-
describe('generateHooks — Bug #53: key factory includes query params when operation has both path and query params', () => {
|
|
220
|
-
const specWithPathAndQueryParams: OpenAPIV3_1.Document = {
|
|
221
|
-
openapi: '3.1.0',
|
|
222
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
223
|
-
paths: {
|
|
224
|
-
'/templates/{uuid}': {
|
|
225
|
-
get: {
|
|
226
|
-
operationId: 'readTemplateDetails',
|
|
227
|
-
parameters: [
|
|
228
|
-
{ name: 'uuid', in: 'path', required: true, schema: { type: 'string' } },
|
|
229
|
-
{ name: 'platform', in: 'query', schema: { type: 'string' } },
|
|
230
|
-
],
|
|
231
|
-
responses: { '200': { description: 'ok' } },
|
|
232
|
-
},
|
|
233
|
-
},
|
|
234
|
-
},
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
const specWithPathAndRequiredQueryParams: OpenAPIV3_1.Document = {
|
|
238
|
-
openapi: '3.1.0',
|
|
239
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
240
|
-
paths: {
|
|
241
|
-
'/reports/{id}': {
|
|
242
|
-
get: {
|
|
243
|
-
operationId: 'getReport',
|
|
244
|
-
parameters: [
|
|
245
|
-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
|
246
|
-
{ name: 'format', in: 'query', required: true, schema: { type: 'string' } },
|
|
247
|
-
],
|
|
248
|
-
responses: { '200': { description: 'ok' } },
|
|
249
|
-
},
|
|
250
|
-
},
|
|
251
|
-
},
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
it('key factory detail entry includes optional params when path + optional query params', () => {
|
|
255
|
-
const { content } = generateHooks(specWithPathAndQueryParams, { staleTime: 0, gcTime: 0 })
|
|
256
|
-
// Key factory should accept (uuid: string, params?)
|
|
257
|
-
expect(content).toContain('(uuid: string, params?: Parameters<typeof readTemplateDetails>[1])')
|
|
258
|
-
})
|
|
259
|
-
|
|
260
|
-
it('key factory detail entry includes params value in tuple', () => {
|
|
261
|
-
const { content } = generateHooks(specWithPathAndQueryParams, { staleTime: 0, gcTime: 0 })
|
|
262
|
-
// Key tuple should include params
|
|
263
|
-
expect(content).toContain("['templates', uuid, params]")
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
it('hook calls key factory with both path and query params', () => {
|
|
267
|
-
const { content } = generateHooks(specWithPathAndQueryParams, { staleTime: 0, gcTime: 0 })
|
|
268
|
-
expect(content).toContain('templateKeys.detail(uuid!, params)')
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
it('key factory detail entry includes required params when path + required query params', () => {
|
|
272
|
-
const { content } = generateHooks(specWithPathAndRequiredQueryParams, {
|
|
273
|
-
staleTime: 0,
|
|
274
|
-
gcTime: 0,
|
|
275
|
-
})
|
|
276
|
-
// Key factory should accept required params (no ?)
|
|
277
|
-
expect(content).toContain('(id: string, params: Parameters<typeof getReport>[1])')
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
it('hook marks params as required when path param + required query param', () => {
|
|
281
|
-
const { content } = generateHooks(specWithPathAndRequiredQueryParams, {
|
|
282
|
-
staleTime: 0,
|
|
283
|
-
gcTime: 0,
|
|
284
|
-
})
|
|
285
|
-
expect(content).toContain('params: Parameters<typeof getReport>[1]')
|
|
286
|
-
expect(content).not.toContain('params?: Parameters<typeof getReport>[1]')
|
|
287
|
-
})
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
// ── Bug #3: Mutation hook argument shapes ───────────────────────────────────────
|
|
291
|
-
|
|
292
|
-
describe('generateHooks — Bug #54: mutation hook handles all argument shapes', () => {
|
|
293
|
-
const makeSpec = (
|
|
294
|
-
pathSegment: string,
|
|
295
|
-
method: string,
|
|
296
|
-
hasBody: boolean,
|
|
297
|
-
queryParams: Array<{ name: string; required?: boolean }>
|
|
298
|
-
): OpenAPIV3_1.Document => ({
|
|
299
|
-
openapi: '3.1.0',
|
|
300
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
301
|
-
paths: {
|
|
302
|
-
[pathSegment]: {
|
|
303
|
-
[method]: {
|
|
304
|
-
operationId: `${method}Op`,
|
|
305
|
-
parameters: queryParams.map((p) => ({
|
|
306
|
-
name: p.name,
|
|
307
|
-
in: 'query' as const,
|
|
308
|
-
required: p.required,
|
|
309
|
-
schema: { type: 'string' as const },
|
|
310
|
-
})),
|
|
311
|
-
...(hasBody
|
|
312
|
-
? {
|
|
313
|
-
requestBody: {
|
|
314
|
-
required: true,
|
|
315
|
-
content: {
|
|
316
|
-
'application/json': { schema: { $ref: '#/components/schemas/Body' } },
|
|
317
|
-
},
|
|
318
|
-
},
|
|
319
|
-
}
|
|
320
|
-
: {}),
|
|
321
|
-
responses: { '200': { description: 'ok' } },
|
|
322
|
-
},
|
|
323
|
-
},
|
|
324
|
-
},
|
|
325
|
-
components: {
|
|
326
|
-
schemas: {
|
|
327
|
-
Body: { type: 'object' as const, properties: { name: { type: 'string' as const } } },
|
|
328
|
-
},
|
|
329
|
-
},
|
|
330
|
-
})
|
|
331
|
-
|
|
332
|
-
it('mutation with query params only (no body, no path): variables type is Parameters[0], mutationFn is (vars) => fn(vars)', () => {
|
|
333
|
-
const spec = makeSpec('/resources', 'post', false, [{ name: 'filter', required: false }])
|
|
334
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
335
|
-
// Variables type should be Parameters<typeof postOp>[0]
|
|
336
|
-
expect(content).toContain('Parameters<typeof postOp>[0]')
|
|
337
|
-
// mutationFn should pass vars through
|
|
338
|
-
expect(content).toContain('(vars) => postOp(vars)')
|
|
339
|
-
})
|
|
340
|
-
|
|
341
|
-
it('mutation with body + query params (no path): variables is { body, params }, mutationFn destructures', () => {
|
|
342
|
-
const spec = makeSpec('/resources', 'post', true, [{ name: 'dryRun', required: false }])
|
|
343
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
344
|
-
// Variables type includes both body and params
|
|
345
|
-
expect(content).toContain('body: Parameters<typeof postOp>[0]')
|
|
346
|
-
expect(content).toContain('params: Parameters<typeof postOp>[1]')
|
|
347
|
-
// mutationFn destructures and passes both
|
|
348
|
-
expect(content).toContain('({ body, params }) => postOp(body, params)')
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
it('mutation with path + body + query params: variables is { id, body, params }, mutationFn destructures all', () => {
|
|
352
|
-
const spec: OpenAPIV3_1.Document = {
|
|
353
|
-
openapi: '3.1.0',
|
|
354
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
355
|
-
paths: {
|
|
356
|
-
'/resources/{id}': {
|
|
357
|
-
patch: {
|
|
358
|
-
operationId: 'patchOp',
|
|
359
|
-
parameters: [
|
|
360
|
-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
|
361
|
-
{ name: 'dryRun', in: 'query', schema: { type: 'string' } },
|
|
362
|
-
],
|
|
363
|
-
requestBody: {
|
|
364
|
-
required: true,
|
|
365
|
-
content: { 'application/json': { schema: { $ref: '#/components/schemas/Body' } } },
|
|
366
|
-
},
|
|
367
|
-
responses: { '200': { description: 'ok' } },
|
|
368
|
-
},
|
|
369
|
-
},
|
|
370
|
-
},
|
|
371
|
-
components: {
|
|
372
|
-
schemas: { Body: { type: 'object', properties: { name: { type: 'string' } } } },
|
|
373
|
-
},
|
|
374
|
-
}
|
|
375
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
376
|
-
// Variables includes id, body, and params
|
|
377
|
-
expect(content).toContain('id: string')
|
|
378
|
-
expect(content).toContain('body: Parameters<typeof patchOp>[1]')
|
|
379
|
-
expect(content).toContain('params: Parameters<typeof patchOp>[2]')
|
|
380
|
-
// mutationFn destructures all three and passes in correct order
|
|
381
|
-
expect(content).toContain('({ id, body, params }) => patchOp(id, body, params)')
|
|
382
|
-
})
|
|
383
|
-
|
|
384
|
-
it('mutation with path + query params only (no body): variables is { id, params }, mutationFn uses both', () => {
|
|
385
|
-
const spec: OpenAPIV3_1.Document = {
|
|
386
|
-
openapi: '3.1.0',
|
|
387
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
388
|
-
paths: {
|
|
389
|
-
'/resources/{id}': {
|
|
390
|
-
delete: {
|
|
391
|
-
operationId: 'deleteOp',
|
|
392
|
-
parameters: [
|
|
393
|
-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
|
394
|
-
{ name: 'cascade', in: 'query', schema: { type: 'string' } },
|
|
395
|
-
],
|
|
396
|
-
responses: { '204': { description: 'deleted' } },
|
|
397
|
-
},
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
}
|
|
401
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
402
|
-
// Variables includes id and params
|
|
403
|
-
expect(content).toContain('id: string')
|
|
404
|
-
expect(content).toContain('params: Parameters<typeof deleteOp>[1]')
|
|
405
|
-
// mutationFn destructures and passes both
|
|
406
|
-
expect(content).toContain('({ id, params }) => deleteOp(id, params)')
|
|
407
|
-
})
|
|
408
|
-
|
|
409
|
-
it('existing body-only mutation still works: variables is Parameters[0], mutationFn is (vars) => fn(vars)', () => {
|
|
410
|
-
// Regression: existing behavior must be preserved
|
|
411
|
-
const spec: OpenAPIV3_1.Document = {
|
|
412
|
-
openapi: '3.1.0',
|
|
413
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
414
|
-
paths: {
|
|
415
|
-
'/tasks': {
|
|
416
|
-
post: {
|
|
417
|
-
operationId: 'createTask',
|
|
418
|
-
requestBody: {
|
|
419
|
-
required: true,
|
|
420
|
-
content: {
|
|
421
|
-
'application/json': { schema: { $ref: '#/components/schemas/CreateTask' } },
|
|
422
|
-
},
|
|
423
|
-
},
|
|
424
|
-
responses: { '201': { description: 'created' } },
|
|
425
|
-
},
|
|
426
|
-
},
|
|
427
|
-
},
|
|
428
|
-
components: {
|
|
429
|
-
schemas: { CreateTask: { type: 'object', properties: { title: { type: 'string' } } } },
|
|
430
|
-
},
|
|
431
|
-
}
|
|
432
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
433
|
-
expect(content).toContain('Parameters<typeof createTask>[0]')
|
|
434
|
-
expect(content).toContain('(vars) => createTask(vars)')
|
|
435
|
-
})
|
|
436
|
-
})
|
|
437
|
-
|
|
438
|
-
describe('generateHooks — tag name to camelCase key factory', () => {
|
|
439
|
-
// Resource name comes from the path segment, not the tag — use tag name as path to test conversion
|
|
440
|
-
const makeSpec = (pathSegment: string): OpenAPIV3_1.Document => ({
|
|
441
|
-
openapi: '3.1.0',
|
|
442
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
443
|
-
paths: {
|
|
444
|
-
[`/${pathSegment}`]: {
|
|
445
|
-
get: {
|
|
446
|
-
operationId: `list${pathSegment}`,
|
|
447
|
-
responses: {
|
|
448
|
-
'200': {
|
|
449
|
-
description: 'ok',
|
|
450
|
-
content: {
|
|
451
|
-
'application/json': { schema: { type: 'array', items: { type: 'object' } } },
|
|
452
|
-
},
|
|
453
|
-
},
|
|
454
|
-
},
|
|
455
|
-
},
|
|
456
|
-
},
|
|
457
|
-
},
|
|
458
|
-
})
|
|
459
|
-
|
|
460
|
-
it('kebab-case path /page-selections → pageSelectionKeys (valid JS identifier)', () => {
|
|
461
|
-
const { content } = generateHooks(makeSpec('page-selections'), { staleTime: 0, gcTime: 0 })
|
|
462
|
-
expect(content).toContain('export const pageSelectionKeys')
|
|
463
|
-
expect(content).not.toContain('page-selectionKeys')
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
it('snake_case path /group_reservations → groupReservationKeys', () => {
|
|
467
|
-
const { content } = generateHooks(makeSpec('group_reservations'), { staleTime: 0, gcTime: 0 })
|
|
468
|
-
expect(content).toContain('export const groupReservationKeys')
|
|
469
|
-
expect(content).not.toContain('group_reservationKeys')
|
|
470
|
-
})
|
|
471
|
-
|
|
472
|
-
it('plain path /tasks → taskKeys (unchanged)', () => {
|
|
473
|
-
const { content } = generateHooks(makeSpec('tasks'), { staleTime: 0, gcTime: 0 })
|
|
474
|
-
expect(content).toContain('export const taskKeys')
|
|
475
|
-
})
|
|
476
|
-
|
|
477
|
-
it('kebab-case path /ad-settings → adSettingKeys', () => {
|
|
478
|
-
const { content } = generateHooks(makeSpec('ad-settings'), { staleTime: 0, gcTime: 0 })
|
|
479
|
-
expect(content).toContain('export const adSettingKeys')
|
|
480
|
-
expect(content).not.toContain('ad-settingKeys')
|
|
481
|
-
})
|
|
482
|
-
})
|
|
483
|
-
|
|
484
|
-
// ── Feature #60: useSuspenseQuery variants ─────────────────────────────────────
|
|
485
|
-
|
|
486
|
-
describe('generateHooks — Feature #60: suspense query variants', () => {
|
|
487
|
-
const suspenseSpec: OpenAPIV3_1.Document = {
|
|
488
|
-
openapi: '3.1.0',
|
|
489
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
490
|
-
paths: {
|
|
491
|
-
'/api/v1/tasks': {
|
|
492
|
-
get: {
|
|
493
|
-
operationId: 'listTasks',
|
|
494
|
-
responses: { '200': { description: 'ok' } },
|
|
495
|
-
},
|
|
496
|
-
},
|
|
497
|
-
'/api/v1/tasks/{id}': {
|
|
498
|
-
get: {
|
|
499
|
-
operationId: 'getTask',
|
|
500
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
501
|
-
responses: { '200': { description: 'ok' } },
|
|
502
|
-
},
|
|
503
|
-
},
|
|
504
|
-
},
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
it('when suspense: true, output contains useSuspenseGetTask', () => {
|
|
508
|
-
const { content } = generateHooks(suspenseSpec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
509
|
-
expect(content).toContain('export function useSuspenseGetTask')
|
|
510
|
-
})
|
|
511
|
-
|
|
512
|
-
it('when suspense: true, output contains useSuspenseListTasks', () => {
|
|
513
|
-
const { content } = generateHooks(suspenseSpec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
514
|
-
expect(content).toContain('export function useSuspenseListTasks')
|
|
515
|
-
})
|
|
516
|
-
|
|
517
|
-
it('when suspense: true, output imports useSuspenseQuery from @tanstack/react-query', () => {
|
|
518
|
-
const { content } = generateHooks(suspenseSpec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
519
|
-
expect(content).toContain('useSuspenseQuery')
|
|
520
|
-
expect(content).toContain("from '@tanstack/react-query'")
|
|
521
|
-
})
|
|
522
|
-
|
|
523
|
-
it('when suspense: true, output imports UseSuspenseQueryOptions type', () => {
|
|
524
|
-
const { content } = generateHooks(suspenseSpec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
525
|
-
expect(content).toContain('type UseSuspenseQueryOptions')
|
|
526
|
-
})
|
|
527
|
-
|
|
528
|
-
it('suspense variant uses useSuspenseQuery call', () => {
|
|
529
|
-
const { content } = generateHooks(suspenseSpec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
530
|
-
expect(content).toContain('return useSuspenseQuery<')
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
it('suspense detail hook path params are required string (not nullish)', () => {
|
|
534
|
-
const { content } = generateHooks(suspenseSpec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
535
|
-
// useSuspenseGetTask should have id: string (not string | undefined | null)
|
|
536
|
-
const suspenseHookStart = content.indexOf('export function useSuspenseGetTask')
|
|
537
|
-
const suspenseHookEnd = content.indexOf('\n}', suspenseHookStart) + 2
|
|
538
|
-
const suspenseHookContent = content.slice(suspenseHookStart, suspenseHookEnd)
|
|
539
|
-
expect(suspenseHookContent).toContain('id: string')
|
|
540
|
-
expect(suspenseHookContent).not.toContain('id: string | undefined | null')
|
|
541
|
-
})
|
|
542
|
-
|
|
543
|
-
it('when suspense: false (default), no useSuspense* hooks generated', () => {
|
|
544
|
-
const { content } = generateHooks(suspenseSpec, { staleTime: 0, gcTime: 0 })
|
|
545
|
-
expect(content).not.toContain('useSuspenseGetTask')
|
|
546
|
-
expect(content).not.toContain('useSuspenseListTasks')
|
|
547
|
-
expect(content).not.toContain('useSuspenseQuery')
|
|
548
|
-
})
|
|
549
|
-
|
|
550
|
-
it('when suspense: false explicitly, no useSuspense* hooks generated', () => {
|
|
551
|
-
const { content } = generateHooks(suspenseSpec, { staleTime: 0, gcTime: 0, suspense: false })
|
|
552
|
-
expect(content).not.toContain('useSuspenseQuery')
|
|
553
|
-
})
|
|
554
|
-
|
|
555
|
-
it('suspense hook with 1 path param + optional query param generates params? signature', () => {
|
|
556
|
-
// Covers the `pathParams.length === 1 && hasQueryParams` branch (hooks.ts line 309-310)
|
|
557
|
-
// and the `paramsRequired === false` (false) branch at line 296 → paramsToken = 'params?'
|
|
558
|
-
const spec: OpenAPIV3_1.Document = {
|
|
559
|
-
openapi: '3.1.0',
|
|
560
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
561
|
-
paths: {
|
|
562
|
-
'/repos/{repoId}/commits': {
|
|
563
|
-
get: {
|
|
564
|
-
operationId: 'listRepoCommits',
|
|
565
|
-
parameters: [
|
|
566
|
-
{ name: 'repoId', in: 'path', required: true, schema: { type: 'string' } },
|
|
567
|
-
{ name: 'branch', in: 'query', required: false, schema: { type: 'string' } },
|
|
568
|
-
],
|
|
569
|
-
responses: { '200': { description: 'ok' } },
|
|
570
|
-
},
|
|
571
|
-
},
|
|
572
|
-
},
|
|
573
|
-
}
|
|
574
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
575
|
-
expect(content).toContain('export function useSuspenseListRepoCommits')
|
|
576
|
-
expect(content).toContain('repoId: string')
|
|
577
|
-
expect(content).toContain('params?:')
|
|
578
|
-
})
|
|
579
|
-
|
|
580
|
-
it('suspense hook with 1 path param + required query param generates params (non-optional) signature', () => {
|
|
581
|
-
// Covers the `paramsRequired === true` (true) branch at line 296 → paramsToken = 'params'
|
|
582
|
-
const spec: OpenAPIV3_1.Document = {
|
|
583
|
-
openapi: '3.1.0',
|
|
584
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
585
|
-
paths: {
|
|
586
|
-
'/search/{category}': {
|
|
587
|
-
get: {
|
|
588
|
-
operationId: 'searchCategory',
|
|
589
|
-
parameters: [
|
|
590
|
-
{ name: 'category', in: 'path', required: true, schema: { type: 'string' } },
|
|
591
|
-
{ name: 'q', in: 'query', required: true, schema: { type: 'string' } },
|
|
592
|
-
],
|
|
593
|
-
responses: { '200': { description: 'ok' } },
|
|
594
|
-
},
|
|
595
|
-
},
|
|
596
|
-
},
|
|
597
|
-
}
|
|
598
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
599
|
-
expect(content).toContain('export function useSuspenseSearchCategory')
|
|
600
|
-
expect(content).toContain('category: string')
|
|
601
|
-
// Required query param → non-optional params token (no ?)
|
|
602
|
-
expect(content).toContain('params: Parameters')
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
it('suspense hook with 2 path params builds multi-param queryKey call', () => {
|
|
606
|
-
// Covers the multi-path-param (pathParams.length > 1) queryKey branch in suspense generation
|
|
607
|
-
const spec: OpenAPIV3_1.Document = {
|
|
608
|
-
openapi: '3.1.0',
|
|
609
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
610
|
-
paths: {
|
|
611
|
-
'/orgs/{orgId}/repos/{repoId}': {
|
|
612
|
-
get: {
|
|
613
|
-
operationId: 'getOrgRepo',
|
|
614
|
-
parameters: [
|
|
615
|
-
{ name: 'orgId', in: 'path', required: true, schema: { type: 'string' } },
|
|
616
|
-
{ name: 'repoId', in: 'path', required: true, schema: { type: 'string' } },
|
|
617
|
-
],
|
|
618
|
-
responses: { '200': { description: 'ok' } },
|
|
619
|
-
},
|
|
620
|
-
},
|
|
621
|
-
},
|
|
622
|
-
}
|
|
623
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
624
|
-
expect(content).toContain('export function useSuspenseGetOrgRepo')
|
|
625
|
-
// Both path params appear in the function signature
|
|
626
|
-
expect(content).toContain('orgId: string')
|
|
627
|
-
expect(content).toContain('repoId: string')
|
|
628
|
-
})
|
|
629
|
-
|
|
630
|
-
it('suspense hook with 2 path params + query params builds correct queryKey', () => {
|
|
631
|
-
// Covers the else branch: pathParams.length > 1 && hasQueryParams
|
|
632
|
-
const spec: OpenAPIV3_1.Document = {
|
|
633
|
-
openapi: '3.1.0',
|
|
634
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
635
|
-
paths: {
|
|
636
|
-
'/orgs/{orgId}/repos/{repoId}/commits': {
|
|
637
|
-
get: {
|
|
638
|
-
operationId: 'listOrgRepoCommits',
|
|
639
|
-
parameters: [
|
|
640
|
-
{ name: 'orgId', in: 'path', required: true, schema: { type: 'string' } },
|
|
641
|
-
{ name: 'repoId', in: 'path', required: true, schema: { type: 'string' } },
|
|
642
|
-
{ name: 'branch', in: 'query', required: false, schema: { type: 'string' } },
|
|
643
|
-
],
|
|
644
|
-
responses: { '200': { description: 'ok' } },
|
|
645
|
-
},
|
|
646
|
-
},
|
|
647
|
-
},
|
|
648
|
-
}
|
|
649
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
650
|
-
expect(content).toContain('export function useSuspenseListOrgRepoCommits')
|
|
651
|
-
expect(content).toContain('orgId: string')
|
|
652
|
-
expect(content).toContain('repoId: string')
|
|
653
|
-
expect(content).toContain('params?:')
|
|
654
|
-
})
|
|
655
|
-
|
|
656
|
-
it('deprecated GET with suspense: true emits @deprecated JSDoc on the suspense hook', () => {
|
|
657
|
-
// Covers the `if (op.deprecated)` branch in suspense hook generation
|
|
658
|
-
const spec: OpenAPIV3_1.Document = {
|
|
659
|
-
openapi: '3.1.0',
|
|
660
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
661
|
-
paths: {
|
|
662
|
-
'/legacy': {
|
|
663
|
-
get: {
|
|
664
|
-
operationId: 'listLegacy',
|
|
665
|
-
deprecated: true,
|
|
666
|
-
responses: { '200': { description: 'ok' } },
|
|
667
|
-
},
|
|
668
|
-
},
|
|
669
|
-
},
|
|
670
|
-
}
|
|
671
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0, suspense: true })
|
|
672
|
-
const suspenseIdx = content.indexOf('export function useSuspenseListLegacy')
|
|
673
|
-
expect(suspenseIdx).toBeGreaterThan(-1)
|
|
674
|
-
// @deprecated comment appears immediately before the function declaration
|
|
675
|
-
const before = content.slice(0, suspenseIdx)
|
|
676
|
-
expect(before.slice(before.lastIndexOf('/**'))).toContain('@deprecated')
|
|
677
|
-
})
|
|
678
|
-
})
|
|
679
|
-
|
|
680
|
-
// ── Feature #59: Per-resource staleTime and gcTime overrides ───────────────────
|
|
681
|
-
|
|
682
|
-
describe('generateHooks — Feature #59: per-resource cache timing overrides', () => {
|
|
683
|
-
const multiResourceSpec: OpenAPIV3_1.Document = {
|
|
684
|
-
openapi: '3.1.0',
|
|
685
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
686
|
-
paths: {
|
|
687
|
-
'/api/v1/tasks': {
|
|
688
|
-
get: {
|
|
689
|
-
operationId: 'listTasks',
|
|
690
|
-
responses: { '200': { description: 'ok' } },
|
|
691
|
-
},
|
|
692
|
-
},
|
|
693
|
-
'/api/v1/tasks/{id}': {
|
|
694
|
-
get: {
|
|
695
|
-
operationId: 'getTask',
|
|
696
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
697
|
-
responses: { '200': { description: 'ok' } },
|
|
698
|
-
},
|
|
699
|
-
},
|
|
700
|
-
'/api/v1/platforms': {
|
|
701
|
-
get: {
|
|
702
|
-
operationId: 'listPlatforms',
|
|
703
|
-
responses: { '200': { description: 'ok' } },
|
|
704
|
-
},
|
|
705
|
-
},
|
|
706
|
-
},
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
it('overridden resource uses override staleTime and gcTime', () => {
|
|
710
|
-
const { content } = generateHooks(multiResourceSpec, {
|
|
711
|
-
staleTime: 0,
|
|
712
|
-
gcTime: 300_000,
|
|
713
|
-
overrides: { tasks: { staleTime: 5000, gcTime: 60_000 } },
|
|
714
|
-
})
|
|
715
|
-
// useListTasks should use override staleTime 5000
|
|
716
|
-
const listTasksStart = content.indexOf('export function useListTasks')
|
|
717
|
-
const listTasksEnd = content.indexOf('\n}', listTasksStart) + 2
|
|
718
|
-
const listTasksContent = content.slice(listTasksStart, listTasksEnd)
|
|
719
|
-
expect(listTasksContent).toContain('staleTime: 5000,')
|
|
720
|
-
expect(listTasksContent).toContain('gcTime: 60000,')
|
|
721
|
-
})
|
|
722
|
-
|
|
723
|
-
it('non-overridden resource uses global defaults', () => {
|
|
724
|
-
const { content } = generateHooks(multiResourceSpec, {
|
|
725
|
-
staleTime: 0,
|
|
726
|
-
gcTime: 300_000,
|
|
727
|
-
overrides: { tasks: { staleTime: 5000, gcTime: 60_000 } },
|
|
728
|
-
})
|
|
729
|
-
// useListPlatforms should use global staleTime 0
|
|
730
|
-
const listPlatformsStart = content.indexOf('export function useListPlatforms')
|
|
731
|
-
const listPlatformsEnd = content.indexOf('\n}', listPlatformsStart) + 2
|
|
732
|
-
const listPlatformsContent = content.slice(listPlatformsStart, listPlatformsEnd)
|
|
733
|
-
expect(listPlatformsContent).toContain('staleTime: 0,')
|
|
734
|
-
expect(listPlatformsContent).toContain('gcTime: 300000,')
|
|
735
|
-
})
|
|
736
|
-
|
|
737
|
-
it('overrides apply to both list and detail hooks of the same resource', () => {
|
|
738
|
-
const { content } = generateHooks(multiResourceSpec, {
|
|
739
|
-
staleTime: 0,
|
|
740
|
-
gcTime: 300_000,
|
|
741
|
-
overrides: { tasks: { staleTime: 5000, gcTime: 60_000 } },
|
|
742
|
-
})
|
|
743
|
-
// useGetTask (detail) should also use override staleTime
|
|
744
|
-
const getTaskStart = content.indexOf('export function useGetTask')
|
|
745
|
-
const getTaskEnd = content.indexOf('\n}', getTaskStart) + 2
|
|
746
|
-
const getTaskContent = content.slice(getTaskStart, getTaskEnd)
|
|
747
|
-
expect(getTaskContent).toContain('staleTime: 5000,')
|
|
748
|
-
expect(getTaskContent).toContain('gcTime: 60000,')
|
|
749
|
-
})
|
|
750
|
-
|
|
751
|
-
it('no overrides — all hooks use global staleTime and gcTime', () => {
|
|
752
|
-
const { content } = generateHooks(multiResourceSpec, {
|
|
753
|
-
staleTime: 1000,
|
|
754
|
-
gcTime: 120_000,
|
|
755
|
-
})
|
|
756
|
-
// Count staleTime: 1000, occurrences — should appear for each query hook
|
|
757
|
-
const occurrences = content.split('staleTime: 1000,').length - 1
|
|
758
|
-
expect(occurrences).toBeGreaterThanOrEqual(3) // listTasks, getTask, listPlatforms
|
|
759
|
-
})
|
|
760
|
-
})
|
|
761
|
-
|
|
762
|
-
// ── Bug #75: Auto-invalidation key name with multiple detail ops ───────────────
|
|
763
|
-
|
|
764
|
-
describe('generateHooks — Bug #75: auto-invalidation skips detail when multiple detail ops exist', () => {
|
|
765
|
-
const multiDetailSpec: OpenAPIV3_1.Document = {
|
|
766
|
-
openapi: '3.1.0',
|
|
767
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
768
|
-
paths: {
|
|
769
|
-
'/items': {
|
|
770
|
-
get: {
|
|
771
|
-
operationId: 'listItems',
|
|
772
|
-
responses: { '200': { description: 'ok' } },
|
|
773
|
-
},
|
|
774
|
-
},
|
|
775
|
-
'/items/{id}': {
|
|
776
|
-
get: {
|
|
777
|
-
operationId: 'getItem',
|
|
778
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
779
|
-
responses: { '200': { description: 'ok' } },
|
|
780
|
-
},
|
|
781
|
-
put: {
|
|
782
|
-
operationId: 'updateItem',
|
|
783
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
784
|
-
requestBody: {
|
|
785
|
-
required: true,
|
|
786
|
-
content: {
|
|
787
|
-
'application/json': {
|
|
788
|
-
schema: { type: 'object', properties: { name: { type: 'string' } } },
|
|
789
|
-
},
|
|
790
|
-
},
|
|
791
|
-
},
|
|
792
|
-
responses: { '200': { description: 'ok' } },
|
|
793
|
-
},
|
|
794
|
-
},
|
|
795
|
-
'/items/{id}/summary': {
|
|
796
|
-
get: {
|
|
797
|
-
operationId: 'getItemSummary',
|
|
798
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
799
|
-
responses: { '200': { description: 'ok' } },
|
|
800
|
-
},
|
|
801
|
-
},
|
|
802
|
-
},
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
it('key factory does NOT use "detail:" when there are multiple detail ops', () => {
|
|
806
|
-
const { content } = generateHooks(multiDetailSpec, {
|
|
807
|
-
staleTime: 0,
|
|
808
|
-
gcTime: 0,
|
|
809
|
-
autoInvalidate: true,
|
|
810
|
-
})
|
|
811
|
-
// With 2 detail ops (getItem + getItemSummary), the key factory uses operation-derived names
|
|
812
|
-
expect(content).not.toContain('detail:')
|
|
813
|
-
})
|
|
814
|
-
|
|
815
|
-
it('mutation hook does NOT reference itemKeys.detail( when there are multiple detail ops', () => {
|
|
816
|
-
const { content } = generateHooks(multiDetailSpec, {
|
|
817
|
-
staleTime: 0,
|
|
818
|
-
gcTime: 0,
|
|
819
|
-
autoInvalidate: true,
|
|
820
|
-
})
|
|
821
|
-
const updateStart = content.indexOf('export function useUpdateItem')
|
|
822
|
-
const updateEnd = content.indexOf('\n}', updateStart) + 2
|
|
823
|
-
const updateContent = content.slice(updateStart, updateEnd)
|
|
824
|
-
expect(updateContent).not.toContain('itemKeys.detail(')
|
|
825
|
-
})
|
|
826
|
-
|
|
827
|
-
it('mutation hook DOES contain itemKeys.all() for broad invalidation', () => {
|
|
828
|
-
const { content } = generateHooks(multiDetailSpec, {
|
|
829
|
-
staleTime: 0,
|
|
830
|
-
gcTime: 0,
|
|
831
|
-
autoInvalidate: true,
|
|
832
|
-
})
|
|
833
|
-
const updateStart = content.indexOf('export function useUpdateItem')
|
|
834
|
-
const updateEnd = content.indexOf('\n}', updateStart) + 2
|
|
835
|
-
const updateContent = content.slice(updateStart, updateEnd)
|
|
836
|
-
expect(updateContent).toContain('queryClient.invalidateQueries({ queryKey: itemKeys.all() })')
|
|
837
|
-
})
|
|
838
|
-
})
|
|
839
|
-
|
|
840
|
-
// ── Snapshot: full generated output ───────────────────────────────────────────
|
|
841
|
-
|
|
842
|
-
describe('snapshot: full generated output', () => {
|
|
843
|
-
it('default options (staleTime: 0, gcTime: 300_000) — baseline', () => {
|
|
844
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 300_000 })
|
|
845
|
-
expect(content).toMatchSnapshot()
|
|
846
|
-
})
|
|
847
|
-
|
|
848
|
-
it('autoInvalidate: true — auto-invalidation wiring', () => {
|
|
849
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 300_000, autoInvalidate: true })
|
|
850
|
-
expect(content).toMatchSnapshot()
|
|
851
|
-
})
|
|
852
|
-
|
|
853
|
-
it('suspense: true — suspense hook variants', () => {
|
|
854
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 300_000, suspense: true })
|
|
855
|
-
expect(content).toMatchSnapshot()
|
|
856
|
-
})
|
|
857
|
-
|
|
858
|
-
it('overrides: { tasks: { staleTime: 60_000, gcTime: 600_000 } } — per-resource timing', () => {
|
|
859
|
-
const { content } = generateHooks(spec, {
|
|
860
|
-
staleTime: 0,
|
|
861
|
-
gcTime: 300_000,
|
|
862
|
-
overrides: { tasks: { staleTime: 60_000, gcTime: 600_000 } },
|
|
863
|
-
})
|
|
864
|
-
expect(content).toMatchSnapshot()
|
|
865
|
-
})
|
|
866
|
-
|
|
867
|
-
it('autoInvalidate: true, suspense: true, overrides: { tasks: { staleTime: 60_000 } } — all features combined', () => {
|
|
868
|
-
const { content } = generateHooks(spec, {
|
|
869
|
-
staleTime: 0,
|
|
870
|
-
gcTime: 300_000,
|
|
871
|
-
autoInvalidate: true,
|
|
872
|
-
suspense: true,
|
|
873
|
-
overrides: { tasks: { staleTime: 60_000 } },
|
|
874
|
-
})
|
|
875
|
-
expect(content).toMatchSnapshot()
|
|
876
|
-
})
|
|
877
|
-
})
|
|
878
|
-
|
|
879
|
-
// ── Feature interactions ────────────────────────────────────────────────────────
|
|
880
|
-
|
|
881
|
-
describe('feature interactions', () => {
|
|
882
|
-
it('Test A: suspense + overrides apply correct staleTime to suspense and regular hooks', () => {
|
|
883
|
-
const { content } = generateHooks(spec, {
|
|
884
|
-
staleTime: 0,
|
|
885
|
-
gcTime: 300_000,
|
|
886
|
-
suspense: true,
|
|
887
|
-
overrides: { tasks: { staleTime: 60_000, gcTime: 600_000 } },
|
|
888
|
-
})
|
|
889
|
-
|
|
890
|
-
// Suspense hook for getTask should exist and contain the overridden staleTime
|
|
891
|
-
const suspenseHookStart = content.indexOf('export function useSuspenseGetTask')
|
|
892
|
-
const suspenseHookEnd = content.indexOf('\n}', suspenseHookStart) + 2
|
|
893
|
-
const suspenseHookContent = content.slice(suspenseHookStart, suspenseHookEnd)
|
|
894
|
-
expect(suspenseHookContent).toContain('staleTime: 60000,')
|
|
895
|
-
|
|
896
|
-
// Regular useGetTask hook should also get the override
|
|
897
|
-
const regularHookStart = content.indexOf('export function useGetTask')
|
|
898
|
-
const regularHookEnd = content.indexOf('\n}', regularHookStart) + 2
|
|
899
|
-
const regularHookContent = content.slice(regularHookStart, regularHookEnd)
|
|
900
|
-
expect(regularHookContent).toContain('staleTime: 60000,')
|
|
901
|
-
})
|
|
902
|
-
|
|
903
|
-
it('Test B: autoInvalidate + overrides coexist without conflict', () => {
|
|
904
|
-
const { content } = generateHooks(spec, {
|
|
905
|
-
staleTime: 0,
|
|
906
|
-
gcTime: 300_000,
|
|
907
|
-
autoInvalidate: true,
|
|
908
|
-
overrides: { tasks: { staleTime: 30_000 } },
|
|
909
|
-
})
|
|
910
|
-
|
|
911
|
-
// Mutation hook still has invalidateQueries (auto-invalidation works)
|
|
912
|
-
const createHookStart = content.indexOf('export function useCreateTask')
|
|
913
|
-
const createHookEnd = content.indexOf('\n}', createHookStart) + 2
|
|
914
|
-
const createHookContent = content.slice(createHookStart, createHookEnd)
|
|
915
|
-
expect(createHookContent).toContain('invalidateQueries')
|
|
916
|
-
|
|
917
|
-
// Query hook still has the overridden staleTime (overrides work)
|
|
918
|
-
const listHookStart = content.indexOf('export function useListTasks')
|
|
919
|
-
const listHookEnd = content.indexOf('\n}', listHookStart) + 2
|
|
920
|
-
const listHookContent = content.slice(listHookStart, listHookEnd)
|
|
921
|
-
expect(listHookContent).toContain('staleTime: 30000,')
|
|
922
|
-
})
|
|
923
|
-
|
|
924
|
-
it('Test C: suspense + autoInvalidate produces both variants and invalidation', () => {
|
|
925
|
-
const { content } = generateHooks(spec, {
|
|
926
|
-
staleTime: 0,
|
|
927
|
-
gcTime: 300_000,
|
|
928
|
-
suspense: true,
|
|
929
|
-
autoInvalidate: true,
|
|
930
|
-
})
|
|
931
|
-
|
|
932
|
-
// Both regular and suspense hooks are generated
|
|
933
|
-
expect(content).toContain('export function useListTasks')
|
|
934
|
-
expect(content).toContain('export function useSuspenseListTasks')
|
|
935
|
-
expect(content).toContain('export function useGetTask')
|
|
936
|
-
expect(content).toContain('export function useSuspenseGetTask')
|
|
937
|
-
|
|
938
|
-
// Mutation hooks still have invalidateQueries
|
|
939
|
-
const createHookStart = content.indexOf('export function useCreateTask')
|
|
940
|
-
const createHookEnd = content.indexOf('\n}', createHookStart) + 2
|
|
941
|
-
const createHookContent = content.slice(createHookStart, createHookEnd)
|
|
942
|
-
expect(createHookContent).toContain('invalidateQueries')
|
|
943
|
-
})
|
|
944
|
-
})
|
|
945
|
-
|
|
946
|
-
describe('auto-invalidate mutations (#58)', () => {
|
|
947
|
-
it('autoInvalidate: false — no useQueryClient or invalidateQueries in output', () => {
|
|
948
|
-
const { content } = generateHooks(spec, {
|
|
949
|
-
staleTime: 0,
|
|
950
|
-
gcTime: 300_000,
|
|
951
|
-
autoInvalidate: false,
|
|
952
|
-
})
|
|
953
|
-
expect(content).not.toContain('useQueryClient')
|
|
954
|
-
expect(content).not.toContain('invalidateQueries')
|
|
955
|
-
})
|
|
956
|
-
|
|
957
|
-
it('autoInvalidate: true — imports useQueryClient from @tanstack/react-query', () => {
|
|
958
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 300_000, autoInvalidate: true })
|
|
959
|
-
expect(content).toContain('useQueryClient')
|
|
960
|
-
expect(content).toContain("from '@tanstack/react-query'")
|
|
961
|
-
})
|
|
962
|
-
|
|
963
|
-
it('autoInvalidate: true — useCreateTask invalidates taskKeys.all()', () => {
|
|
964
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 300_000, autoInvalidate: true })
|
|
965
|
-
const createStart = content.indexOf('export function useCreateTask')
|
|
966
|
-
const createEnd = content.indexOf('\n}', createStart) + 2
|
|
967
|
-
const createContent = content.slice(createStart, createEnd)
|
|
968
|
-
expect(createContent).toContain('queryClient.invalidateQueries({ queryKey: taskKeys.all() })')
|
|
969
|
-
// Uses spread pattern — no named (data, variables, context) args
|
|
970
|
-
expect(createContent).toContain('onSuccess: (...args) =>')
|
|
971
|
-
expect(createContent).toContain('options?.onSuccess?.(...args)')
|
|
972
|
-
// No detail invalidation for POST — no destructure needed
|
|
973
|
-
expect(createContent).not.toContain('const [, variables] = args')
|
|
974
|
-
})
|
|
975
|
-
|
|
976
|
-
it('autoInvalidate: true — useUpdateTask invalidates taskKeys.all() and taskKeys.detail()', () => {
|
|
977
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 300_000, autoInvalidate: true })
|
|
978
|
-
const updateStart = content.indexOf('export function useUpdateTask')
|
|
979
|
-
const updateEnd = content.indexOf('\n}', updateStart) + 2
|
|
980
|
-
const updateContent = content.slice(updateStart, updateEnd)
|
|
981
|
-
expect(updateContent).toContain('queryClient.invalidateQueries({ queryKey: taskKeys.all() })')
|
|
982
|
-
expect(updateContent).toContain('taskKeys.detail(')
|
|
983
|
-
// Uses spread pattern
|
|
984
|
-
expect(updateContent).toContain('onSuccess: (...args) =>')
|
|
985
|
-
expect(updateContent).toContain('options?.onSuccess?.(...args)')
|
|
986
|
-
// PUT with path param — destructures variables from args for detail invalidation
|
|
987
|
-
expect(updateContent).toContain('const [, variables] = args')
|
|
988
|
-
})
|
|
989
|
-
|
|
990
|
-
it('autoInvalidate: true — useDeleteTask invalidates taskKeys.all() but not detail', () => {
|
|
991
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 300_000, autoInvalidate: true })
|
|
992
|
-
const deleteStart = content.indexOf('export function useDeleteTask')
|
|
993
|
-
const deleteEnd = content.indexOf('\n}', deleteStart) + 2
|
|
994
|
-
const deleteContent = content.slice(deleteStart, deleteEnd)
|
|
995
|
-
expect(deleteContent).toContain('queryClient.invalidateQueries({ queryKey: taskKeys.all() })')
|
|
996
|
-
// DELETE should NOT invalidate detail (only PUT/PATCH do)
|
|
997
|
-
expect(deleteContent).not.toContain('taskKeys.detail(')
|
|
998
|
-
// Uses spread pattern
|
|
999
|
-
expect(deleteContent).toContain('onSuccess: (...args) =>')
|
|
1000
|
-
expect(deleteContent).toContain('options?.onSuccess?.(...args)')
|
|
1001
|
-
// No detail invalidation for DELETE — no destructure needed
|
|
1002
|
-
expect(deleteContent).not.toContain('const [, variables] = args')
|
|
1003
|
-
})
|
|
1004
|
-
})
|
|
1005
|
-
|
|
1006
|
-
// ── All 12 mutation hook variable shapes ────────────────────────────────────────
|
|
1007
|
-
|
|
1008
|
-
describe('mutation hook variables type — all 12 shapes', () => {
|
|
1009
|
-
// Single spec covering all 12 (pathParams × hasBody × hasQueryParams) branches
|
|
1010
|
-
const allShapesSpec: OpenAPIV3_1.Document = {
|
|
1011
|
-
openapi: '3.1.0',
|
|
1012
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1013
|
-
paths: {
|
|
1014
|
-
// Case 1: 0 path params, no body, no query → void
|
|
1015
|
-
'/a': {
|
|
1016
|
-
delete: {
|
|
1017
|
-
operationId: 'deleteA',
|
|
1018
|
-
responses: { '204': { description: 'no content' } },
|
|
1019
|
-
},
|
|
1020
|
-
},
|
|
1021
|
-
// Case 2: 0 path params, no body, has query → Parameters<typeof fn>[0]
|
|
1022
|
-
'/b': {
|
|
1023
|
-
post: {
|
|
1024
|
-
operationId: 'postB',
|
|
1025
|
-
parameters: [{ name: 'filter', in: 'query', schema: { type: 'string' } }],
|
|
1026
|
-
responses: { '200': { description: 'ok' } },
|
|
1027
|
-
},
|
|
1028
|
-
},
|
|
1029
|
-
// Case 3: 0 path params, has body, no query → Parameters<typeof fn>[0]
|
|
1030
|
-
'/c': {
|
|
1031
|
-
post: {
|
|
1032
|
-
operationId: 'postC',
|
|
1033
|
-
requestBody: {
|
|
1034
|
-
required: true,
|
|
1035
|
-
content: {
|
|
1036
|
-
'application/json': {
|
|
1037
|
-
schema: {
|
|
1038
|
-
type: 'object' as const,
|
|
1039
|
-
properties: { name: { type: 'string' as const } },
|
|
1040
|
-
},
|
|
1041
|
-
},
|
|
1042
|
-
},
|
|
1043
|
-
},
|
|
1044
|
-
responses: { '201': { description: 'created' } },
|
|
1045
|
-
},
|
|
1046
|
-
},
|
|
1047
|
-
// Case 4: 0 path params, has body, has query → { body: ...[0]; params: ...[1] }
|
|
1048
|
-
'/d': {
|
|
1049
|
-
post: {
|
|
1050
|
-
operationId: 'postD',
|
|
1051
|
-
parameters: [{ name: 'dryRun', in: 'query', schema: { type: 'string' } }],
|
|
1052
|
-
requestBody: {
|
|
1053
|
-
required: true,
|
|
1054
|
-
content: {
|
|
1055
|
-
'application/json': {
|
|
1056
|
-
schema: {
|
|
1057
|
-
type: 'object' as const,
|
|
1058
|
-
properties: { name: { type: 'string' as const } },
|
|
1059
|
-
},
|
|
1060
|
-
},
|
|
1061
|
-
},
|
|
1062
|
-
},
|
|
1063
|
-
responses: { '200': { description: 'ok' } },
|
|
1064
|
-
},
|
|
1065
|
-
},
|
|
1066
|
-
// Case 5: 1 path param, no body, no query → string
|
|
1067
|
-
'/e/{id}': {
|
|
1068
|
-
delete: {
|
|
1069
|
-
operationId: 'deleteE',
|
|
1070
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1071
|
-
responses: { '204': { description: 'no content' } },
|
|
1072
|
-
},
|
|
1073
|
-
},
|
|
1074
|
-
// Case 6: 1 path param, no body, has query → { id: string; params: ...[1] }
|
|
1075
|
-
'/f/{id}': {
|
|
1076
|
-
delete: {
|
|
1077
|
-
operationId: 'deleteF',
|
|
1078
|
-
parameters: [
|
|
1079
|
-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
|
1080
|
-
{ name: 'cascade', in: 'query', schema: { type: 'string' } },
|
|
1081
|
-
],
|
|
1082
|
-
responses: { '204': { description: 'no content' } },
|
|
1083
|
-
},
|
|
1084
|
-
},
|
|
1085
|
-
// Case 7: 1 path param, has body, no query → { id: string; body: ...[1] }
|
|
1086
|
-
'/g/{id}': {
|
|
1087
|
-
put: {
|
|
1088
|
-
operationId: 'putG',
|
|
1089
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1090
|
-
requestBody: {
|
|
1091
|
-
required: true,
|
|
1092
|
-
content: {
|
|
1093
|
-
'application/json': {
|
|
1094
|
-
schema: {
|
|
1095
|
-
type: 'object' as const,
|
|
1096
|
-
properties: { name: { type: 'string' as const } },
|
|
1097
|
-
},
|
|
1098
|
-
},
|
|
1099
|
-
},
|
|
1100
|
-
},
|
|
1101
|
-
responses: { '200': { description: 'ok' } },
|
|
1102
|
-
},
|
|
1103
|
-
},
|
|
1104
|
-
// Case 8: 1 path param, has body, has query → { id: string; body: ...[1]; params: ...[2] }
|
|
1105
|
-
'/h/{id}': {
|
|
1106
|
-
put: {
|
|
1107
|
-
operationId: 'putH',
|
|
1108
|
-
parameters: [
|
|
1109
|
-
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
|
|
1110
|
-
{ name: 'dryRun', in: 'query', schema: { type: 'string' } },
|
|
1111
|
-
],
|
|
1112
|
-
requestBody: {
|
|
1113
|
-
required: true,
|
|
1114
|
-
content: {
|
|
1115
|
-
'application/json': {
|
|
1116
|
-
schema: {
|
|
1117
|
-
type: 'object' as const,
|
|
1118
|
-
properties: { name: { type: 'string' as const } },
|
|
1119
|
-
},
|
|
1120
|
-
},
|
|
1121
|
-
},
|
|
1122
|
-
},
|
|
1123
|
-
responses: { '200': { description: 'ok' } },
|
|
1124
|
-
},
|
|
1125
|
-
},
|
|
1126
|
-
// Case 9: 2+ path params, no body, no query → { projectId: string; taskId: string }
|
|
1127
|
-
'/i/{projectId}/{taskId}': {
|
|
1128
|
-
delete: {
|
|
1129
|
-
operationId: 'deleteI',
|
|
1130
|
-
parameters: [
|
|
1131
|
-
{ name: 'projectId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1132
|
-
{ name: 'taskId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1133
|
-
],
|
|
1134
|
-
responses: { '204': { description: 'no content' } },
|
|
1135
|
-
},
|
|
1136
|
-
},
|
|
1137
|
-
// Case 10: 2+ path params, no body, has query → { projectId: string; taskId: string; params: ...[2] }
|
|
1138
|
-
'/j/{projectId}/{taskId}': {
|
|
1139
|
-
delete: {
|
|
1140
|
-
operationId: 'deleteJ',
|
|
1141
|
-
parameters: [
|
|
1142
|
-
{ name: 'projectId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1143
|
-
{ name: 'taskId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1144
|
-
{ name: 'recursive', in: 'query', schema: { type: 'string' } },
|
|
1145
|
-
],
|
|
1146
|
-
responses: { '204': { description: 'no content' } },
|
|
1147
|
-
},
|
|
1148
|
-
},
|
|
1149
|
-
// Case 11: 2+ path params, has body, no query → { projectId: string; taskId: string; body: ...[2] }
|
|
1150
|
-
'/k/{projectId}/{taskId}': {
|
|
1151
|
-
put: {
|
|
1152
|
-
operationId: 'putK',
|
|
1153
|
-
parameters: [
|
|
1154
|
-
{ name: 'projectId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1155
|
-
{ name: 'taskId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1156
|
-
],
|
|
1157
|
-
requestBody: {
|
|
1158
|
-
required: true,
|
|
1159
|
-
content: {
|
|
1160
|
-
'application/json': {
|
|
1161
|
-
schema: {
|
|
1162
|
-
type: 'object' as const,
|
|
1163
|
-
properties: { name: { type: 'string' as const } },
|
|
1164
|
-
},
|
|
1165
|
-
},
|
|
1166
|
-
},
|
|
1167
|
-
},
|
|
1168
|
-
responses: { '200': { description: 'ok' } },
|
|
1169
|
-
},
|
|
1170
|
-
},
|
|
1171
|
-
// Case 12: 2+ path params, has body, has query → { projectId: string; taskId: string; body: ...[2]; params: ...[3] }
|
|
1172
|
-
'/l/{projectId}/{taskId}': {
|
|
1173
|
-
put: {
|
|
1174
|
-
operationId: 'putL',
|
|
1175
|
-
parameters: [
|
|
1176
|
-
{ name: 'projectId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1177
|
-
{ name: 'taskId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1178
|
-
{ name: 'dryRun', in: 'query', schema: { type: 'string' } },
|
|
1179
|
-
],
|
|
1180
|
-
requestBody: {
|
|
1181
|
-
required: true,
|
|
1182
|
-
content: {
|
|
1183
|
-
'application/json': {
|
|
1184
|
-
schema: {
|
|
1185
|
-
type: 'object' as const,
|
|
1186
|
-
properties: { name: { type: 'string' as const } },
|
|
1187
|
-
},
|
|
1188
|
-
},
|
|
1189
|
-
},
|
|
1190
|
-
},
|
|
1191
|
-
responses: { '200': { description: 'ok' } },
|
|
1192
|
-
},
|
|
1193
|
-
},
|
|
1194
|
-
},
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
|
-
const { content: shapesContent } = generateHooks(allShapesSpec, { staleTime: 0, gcTime: 0 })
|
|
1198
|
-
|
|
1199
|
-
it('case 1 (0 params, no body, no query): variablesType is void, mutationFn is () => fn()', () => {
|
|
1200
|
-
// UseMutationOptions<..., ApiError, void> — void is the variables type
|
|
1201
|
-
expect(shapesContent).toContain(
|
|
1202
|
-
'UseMutationOptions<Awaited<ReturnType<typeof deleteA>>, ApiError, void>'
|
|
1203
|
-
)
|
|
1204
|
-
expect(shapesContent).toContain('mutationFn: () => deleteA()')
|
|
1205
|
-
})
|
|
1206
|
-
|
|
1207
|
-
it('case 2 (0 params, no body, has query): variablesType is Parameters<typeof fn>[0], mutationFn is (vars) => fn(vars)', () => {
|
|
1208
|
-
expect(shapesContent).toContain(
|
|
1209
|
-
'UseMutationOptions<Awaited<ReturnType<typeof postB>>, ApiError, Parameters<typeof postB>[0]>'
|
|
1210
|
-
)
|
|
1211
|
-
expect(shapesContent).toContain('mutationFn: (vars) => postB(vars)')
|
|
1212
|
-
})
|
|
1213
|
-
|
|
1214
|
-
it('case 3 (0 params, has body, no query): variablesType is Parameters<typeof fn>[0], mutationFn is (vars) => fn(vars)', () => {
|
|
1215
|
-
expect(shapesContent).toContain(
|
|
1216
|
-
'UseMutationOptions<Awaited<ReturnType<typeof postC>>, ApiError, Parameters<typeof postC>[0]>'
|
|
1217
|
-
)
|
|
1218
|
-
expect(shapesContent).toContain('mutationFn: (vars) => postC(vars)')
|
|
1219
|
-
})
|
|
1220
|
-
|
|
1221
|
-
it('case 4 (0 params, has body, has query): variablesType is { body: ...[0]; params: ...[1] }, mutationFn destructures both', () => {
|
|
1222
|
-
expect(shapesContent).toContain(
|
|
1223
|
-
'body: Parameters<typeof postD>[0]; params: Parameters<typeof postD>[1]'
|
|
1224
|
-
)
|
|
1225
|
-
expect(shapesContent).toContain('mutationFn: ({ body, params }) => postD(body, params)')
|
|
1226
|
-
})
|
|
1227
|
-
|
|
1228
|
-
it('case 5 (1 path param, no body, no query): variablesType is string, mutationFn is (id) => fn(id)', () => {
|
|
1229
|
-
// string is the entire variables type — only case that uses literal 'string'
|
|
1230
|
-
expect(shapesContent).toContain(
|
|
1231
|
-
'UseMutationOptions<Awaited<ReturnType<typeof deleteE>>, ApiError, string>'
|
|
1232
|
-
)
|
|
1233
|
-
expect(shapesContent).toContain('mutationFn: (id) => deleteE(id)')
|
|
1234
|
-
})
|
|
1235
|
-
|
|
1236
|
-
it('case 6 (1 path param, no body, has query): variablesType is { id: string; params: ...[1] }, mutationFn destructures { id, params }', () => {
|
|
1237
|
-
expect(shapesContent).toContain('id: string; params: Parameters<typeof deleteF>[1]')
|
|
1238
|
-
expect(shapesContent).toContain('mutationFn: ({ id, params }) => deleteF(id, params)')
|
|
1239
|
-
})
|
|
1240
|
-
|
|
1241
|
-
it('case 7 (1 path param, has body, no query): variablesType is { id: string; body: ...[1] }, mutationFn destructures { id, body }', () => {
|
|
1242
|
-
expect(shapesContent).toContain('id: string; body: Parameters<typeof putG>[1]')
|
|
1243
|
-
expect(shapesContent).toContain('mutationFn: ({ id, body }) => putG(id, body)')
|
|
1244
|
-
})
|
|
1245
|
-
|
|
1246
|
-
it('case 8 (1 path param, has body, has query): variablesType is { id: string; body: ...[1]; params: ...[2] }, mutationFn destructures all', () => {
|
|
1247
|
-
expect(shapesContent).toContain(
|
|
1248
|
-
'id: string; body: Parameters<typeof putH>[1]; params: Parameters<typeof putH>[2]'
|
|
1249
|
-
)
|
|
1250
|
-
expect(shapesContent).toContain('mutationFn: ({ id, body, params }) => putH(id, body, params)')
|
|
1251
|
-
})
|
|
1252
|
-
|
|
1253
|
-
it('case 9 (2+ params, no body, no query): variablesType is { projectId: string; taskId: string }, mutationFn destructures both path params', () => {
|
|
1254
|
-
expect(shapesContent).toContain('projectId: string; taskId: string }')
|
|
1255
|
-
expect(shapesContent).toContain(
|
|
1256
|
-
'mutationFn: ({ projectId, taskId }) => deleteI(projectId, taskId)'
|
|
1257
|
-
)
|
|
1258
|
-
})
|
|
1259
|
-
|
|
1260
|
-
it('case 10 (2+ params, no body, has query): variablesType includes both path params and params, mutationFn passes all', () => {
|
|
1261
|
-
expect(shapesContent).toContain(
|
|
1262
|
-
'projectId: string; taskId: string; params: Parameters<typeof deleteJ>[2]'
|
|
1263
|
-
)
|
|
1264
|
-
expect(shapesContent).toContain(
|
|
1265
|
-
'mutationFn: ({ projectId, taskId, params }) => deleteJ(projectId, taskId, params)'
|
|
1266
|
-
)
|
|
1267
|
-
})
|
|
1268
|
-
|
|
1269
|
-
it('case 11 (2+ params, has body, no query): variablesType includes both path params and body at index [2]', () => {
|
|
1270
|
-
expect(shapesContent).toContain(
|
|
1271
|
-
'projectId: string; taskId: string; body: Parameters<typeof putK>[2]'
|
|
1272
|
-
)
|
|
1273
|
-
expect(shapesContent).toContain(
|
|
1274
|
-
'mutationFn: ({ projectId, taskId, body }) => putK(projectId, taskId, body)'
|
|
1275
|
-
)
|
|
1276
|
-
})
|
|
1277
|
-
|
|
1278
|
-
it('case 12 (2+ params, has body, has query): variablesType includes both path params, body at [2], params at [3]', () => {
|
|
1279
|
-
expect(shapesContent).toContain(
|
|
1280
|
-
'projectId: string; taskId: string; body: Parameters<typeof putL>[2]; params: Parameters<typeof putL>[3]'
|
|
1281
|
-
)
|
|
1282
|
-
expect(shapesContent).toContain(
|
|
1283
|
-
'mutationFn: ({ projectId, taskId, body, params }) => putL(projectId, taskId, body, params)'
|
|
1284
|
-
)
|
|
1285
|
-
})
|
|
1286
|
-
})
|
|
1287
|
-
|
|
1288
|
-
// ── Auto-invalidate: method-based detail invalidation ───────────────────────────
|
|
1289
|
-
|
|
1290
|
-
describe('auto-invalidate: method-based detail invalidation', () => {
|
|
1291
|
-
const thingsSpec: OpenAPIV3_1.Document = {
|
|
1292
|
-
openapi: '3.1.0',
|
|
1293
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1294
|
-
paths: {
|
|
1295
|
-
// GET list — establishes the resource key factory with all(), list(), detail()
|
|
1296
|
-
'/things': {
|
|
1297
|
-
get: {
|
|
1298
|
-
operationId: 'listThings',
|
|
1299
|
-
responses: { '200': { description: 'ok' } },
|
|
1300
|
-
},
|
|
1301
|
-
},
|
|
1302
|
-
'/things/{id}': {
|
|
1303
|
-
// GET detail — needed so detailKeyName = 'detail' (exactly 1 detail op)
|
|
1304
|
-
get: {
|
|
1305
|
-
operationId: 'getThing',
|
|
1306
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1307
|
-
responses: { '200': { description: 'ok' } },
|
|
1308
|
-
},
|
|
1309
|
-
// PUT — should invalidate all() AND detail() (method === 'put', pathParams.length >= 1)
|
|
1310
|
-
put: {
|
|
1311
|
-
operationId: 'updateThing',
|
|
1312
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1313
|
-
requestBody: {
|
|
1314
|
-
required: true,
|
|
1315
|
-
content: {
|
|
1316
|
-
'application/json': {
|
|
1317
|
-
schema: {
|
|
1318
|
-
type: 'object' as const,
|
|
1319
|
-
properties: { name: { type: 'string' as const } },
|
|
1320
|
-
},
|
|
1321
|
-
},
|
|
1322
|
-
},
|
|
1323
|
-
},
|
|
1324
|
-
responses: { '200': { description: 'ok' } },
|
|
1325
|
-
},
|
|
1326
|
-
// PATCH — should invalidate all() AND detail() (method === 'patch', pathParams.length >= 1)
|
|
1327
|
-
patch: {
|
|
1328
|
-
operationId: 'patchThing',
|
|
1329
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1330
|
-
requestBody: {
|
|
1331
|
-
required: true,
|
|
1332
|
-
content: {
|
|
1333
|
-
'application/json': {
|
|
1334
|
-
schema: {
|
|
1335
|
-
type: 'object' as const,
|
|
1336
|
-
properties: { name: { type: 'string' as const } },
|
|
1337
|
-
},
|
|
1338
|
-
},
|
|
1339
|
-
},
|
|
1340
|
-
},
|
|
1341
|
-
responses: { '200': { description: 'ok' } },
|
|
1342
|
-
},
|
|
1343
|
-
// DELETE — should invalidate all() ONLY (method !== 'put'/'patch')
|
|
1344
|
-
delete: {
|
|
1345
|
-
operationId: 'deleteThing',
|
|
1346
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1347
|
-
responses: { '204': { description: 'no content' } },
|
|
1348
|
-
},
|
|
1349
|
-
},
|
|
1350
|
-
},
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
const { content: thingsContent } = generateHooks(thingsSpec, {
|
|
1354
|
-
staleTime: 0,
|
|
1355
|
-
gcTime: 0,
|
|
1356
|
-
autoInvalidate: true,
|
|
1357
|
-
})
|
|
1358
|
-
|
|
1359
|
-
it('thingKeys key factory has a detail entry (exactly one detail GET op)', () => {
|
|
1360
|
-
expect(thingsContent).toContain('export const thingKeys')
|
|
1361
|
-
expect(thingsContent).toContain('detail:')
|
|
1362
|
-
})
|
|
1363
|
-
|
|
1364
|
-
it('useUpdateThing (PUT) invalidates thingKeys.all() AND thingKeys.detail(', () => {
|
|
1365
|
-
const updateStart = thingsContent.indexOf('export function useUpdateThing')
|
|
1366
|
-
const updateEnd = thingsContent.indexOf('\n}', updateStart) + 2
|
|
1367
|
-
const updateHookContent = thingsContent.slice(updateStart, updateEnd)
|
|
1368
|
-
expect(updateHookContent).toContain('thingKeys.all()')
|
|
1369
|
-
expect(updateHookContent).toContain('thingKeys.detail(')
|
|
1370
|
-
})
|
|
1371
|
-
|
|
1372
|
-
it('usePatchThing (PATCH) invalidates thingKeys.all() AND thingKeys.detail(', () => {
|
|
1373
|
-
const patchStart = thingsContent.indexOf('export function usePatchThing')
|
|
1374
|
-
const patchEnd = thingsContent.indexOf('\n}', patchStart) + 2
|
|
1375
|
-
const patchHookContent = thingsContent.slice(patchStart, patchEnd)
|
|
1376
|
-
expect(patchHookContent).toContain('thingKeys.all()')
|
|
1377
|
-
expect(patchHookContent).toContain('thingKeys.detail(')
|
|
1378
|
-
})
|
|
1379
|
-
|
|
1380
|
-
it('useDeleteThing (DELETE) invalidates thingKeys.all() but NOT thingKeys.detail(', () => {
|
|
1381
|
-
const deleteStart = thingsContent.indexOf('export function useDeleteThing')
|
|
1382
|
-
const deleteEnd = thingsContent.indexOf('\n}', deleteStart) + 2
|
|
1383
|
-
const deleteHookContent = thingsContent.slice(deleteStart, deleteEnd)
|
|
1384
|
-
expect(deleteHookContent).toContain('thingKeys.all()')
|
|
1385
|
-
expect(deleteHookContent).not.toContain('thingKeys.detail(')
|
|
1386
|
-
})
|
|
1387
|
-
|
|
1388
|
-
it('PUT hook uses const [, variables] = args to extract id for detail invalidation', () => {
|
|
1389
|
-
const updateStart = thingsContent.indexOf('export function useUpdateThing')
|
|
1390
|
-
const updateEnd = thingsContent.indexOf('\n}', updateStart) + 2
|
|
1391
|
-
const updateHookContent = thingsContent.slice(updateStart, updateEnd)
|
|
1392
|
-
expect(updateHookContent).toContain('const [, variables] = args')
|
|
1393
|
-
})
|
|
1394
|
-
|
|
1395
|
-
it('PATCH hook uses const [, variables] = args to extract id for detail invalidation', () => {
|
|
1396
|
-
const patchStart = thingsContent.indexOf('export function usePatchThing')
|
|
1397
|
-
const patchEnd = thingsContent.indexOf('\n}', patchStart) + 2
|
|
1398
|
-
const patchHookContent = thingsContent.slice(patchStart, patchEnd)
|
|
1399
|
-
expect(patchHookContent).toContain('const [, variables] = args')
|
|
1400
|
-
})
|
|
1401
|
-
|
|
1402
|
-
it('DELETE hook does NOT use const [, variables] = args (no detail invalidation)', () => {
|
|
1403
|
-
const deleteStart = thingsContent.indexOf('export function useDeleteThing')
|
|
1404
|
-
const deleteEnd = thingsContent.indexOf('\n}', deleteStart) + 2
|
|
1405
|
-
const deleteHookContent = thingsContent.slice(deleteStart, deleteEnd)
|
|
1406
|
-
expect(deleteHookContent).not.toContain('const [, variables] = args')
|
|
1407
|
-
})
|
|
1408
|
-
})
|
|
1409
|
-
|
|
1410
|
-
// ── Query hook key builder — 2+ path params with query params ───────────────────
|
|
1411
|
-
|
|
1412
|
-
describe('query hook key builder — 2+ path params with query params', () => {
|
|
1413
|
-
const multiParamQuerySpec: OpenAPIV3_1.Document = {
|
|
1414
|
-
openapi: '3.1.0',
|
|
1415
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1416
|
-
paths: {
|
|
1417
|
-
'/projects/{projectId}/tasks/{taskId}': {
|
|
1418
|
-
get: {
|
|
1419
|
-
operationId: 'getProjectTask',
|
|
1420
|
-
parameters: [
|
|
1421
|
-
{ name: 'projectId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1422
|
-
{ name: 'taskId', in: 'path', required: true, schema: { type: 'string' } },
|
|
1423
|
-
{ name: 'format', in: 'query', schema: { type: 'string' } },
|
|
1424
|
-
],
|
|
1425
|
-
responses: { '200': { description: 'ok' } },
|
|
1426
|
-
},
|
|
1427
|
-
},
|
|
1428
|
-
},
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
const { content: multiParamContent } = generateHooks(multiParamQuerySpec, {
|
|
1432
|
-
staleTime: 0,
|
|
1433
|
-
gcTime: 0,
|
|
1434
|
-
})
|
|
1435
|
-
|
|
1436
|
-
it('key factory detail entry accepts both path params as typed args', () => {
|
|
1437
|
-
expect(multiParamContent).toContain('(projectId: string, taskId: string,')
|
|
1438
|
-
})
|
|
1439
|
-
|
|
1440
|
-
it('hook signature includes both path params as string | undefined | null', () => {
|
|
1441
|
-
expect(multiParamContent).toContain('projectId: string | undefined | null')
|
|
1442
|
-
expect(multiParamContent).toContain('taskId: string | undefined | null')
|
|
1443
|
-
})
|
|
1444
|
-
|
|
1445
|
-
it('enabled guard ANDs both path params', () => {
|
|
1446
|
-
expect(multiParamContent).toContain(
|
|
1447
|
-
'enabled: projectId != null && taskId != null && (options?.enabled ?? true)'
|
|
1448
|
-
)
|
|
1449
|
-
})
|
|
1450
|
-
|
|
1451
|
-
it('queryKey call includes both path params with non-null assertions and query params', () => {
|
|
1452
|
-
expect(multiParamContent).toContain('projectId!, taskId!, params')
|
|
1453
|
-
})
|
|
1454
|
-
})
|
|
1455
|
-
|
|
1456
|
-
// ── Edge cases: deprecated, no operationId, empty spec, multiple list ops ──────
|
|
1457
|
-
|
|
1458
|
-
describe('generateHooks — edge cases', () => {
|
|
1459
|
-
it('marks deprecated mutation hook with @deprecated JSDoc', () => {
|
|
1460
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1461
|
-
openapi: '3.1.0',
|
|
1462
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1463
|
-
paths: {
|
|
1464
|
-
'/pets': {
|
|
1465
|
-
post: {
|
|
1466
|
-
operationId: 'createPet',
|
|
1467
|
-
deprecated: true,
|
|
1468
|
-
requestBody: {
|
|
1469
|
-
required: true,
|
|
1470
|
-
content: { 'application/json': { schema: { type: 'object' } } },
|
|
1471
|
-
},
|
|
1472
|
-
responses: { '201': { description: 'Created' } },
|
|
1473
|
-
},
|
|
1474
|
-
},
|
|
1475
|
-
},
|
|
1476
|
-
}
|
|
1477
|
-
const { content: result } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1478
|
-
expect(result).toContain('/** @deprecated */')
|
|
1479
|
-
})
|
|
1480
|
-
|
|
1481
|
-
it('generates hook name from method+path when operationId is absent', () => {
|
|
1482
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1483
|
-
openapi: '3.1.0',
|
|
1484
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1485
|
-
paths: {
|
|
1486
|
-
'/pets/{id}': {
|
|
1487
|
-
get: {
|
|
1488
|
-
// no operationId
|
|
1489
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1490
|
-
responses: { '200': { description: 'ok' } },
|
|
1491
|
-
},
|
|
1492
|
-
},
|
|
1493
|
-
},
|
|
1494
|
-
}
|
|
1495
|
-
const { content: result } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1496
|
-
// Should have a hook function — exact name depends on deriveOperationName
|
|
1497
|
-
expect(result).toContain('export function use')
|
|
1498
|
-
})
|
|
1499
|
-
|
|
1500
|
-
it('generates valid output for spec with no operations', () => {
|
|
1501
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1502
|
-
openapi: '3.1.0',
|
|
1503
|
-
info: { title: 'Empty', version: '1.0.0' },
|
|
1504
|
-
paths: {},
|
|
1505
|
-
}
|
|
1506
|
-
const { content: result } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1507
|
-
expect(result).toContain("import { type ApiError } from './client.js'")
|
|
1508
|
-
expect(result).not.toContain('export function use')
|
|
1509
|
-
})
|
|
1510
|
-
|
|
1511
|
-
it('generates operation-derived key names when multiple list ops exist for same resource', () => {
|
|
1512
|
-
// Two GET operations with no path params sharing the same primary resource "items"
|
|
1513
|
-
// triggers the listOps.length > 1 branch in key factory generation
|
|
1514
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1515
|
-
openapi: '3.1.0',
|
|
1516
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1517
|
-
paths: {
|
|
1518
|
-
'/items': {
|
|
1519
|
-
get: {
|
|
1520
|
-
operationId: 'listItems',
|
|
1521
|
-
responses: {
|
|
1522
|
-
'200': {
|
|
1523
|
-
description: 'ok',
|
|
1524
|
-
content: {
|
|
1525
|
-
'application/json': { schema: { type: 'array', items: { type: 'object' } } },
|
|
1526
|
-
},
|
|
1527
|
-
},
|
|
1528
|
-
},
|
|
1529
|
-
},
|
|
1530
|
-
},
|
|
1531
|
-
'/items/search': {
|
|
1532
|
-
get: {
|
|
1533
|
-
operationId: 'searchItems',
|
|
1534
|
-
parameters: [{ name: 'q', in: 'query', schema: { type: 'string' } }],
|
|
1535
|
-
responses: {
|
|
1536
|
-
'200': {
|
|
1537
|
-
description: 'ok',
|
|
1538
|
-
content: {
|
|
1539
|
-
'application/json': { schema: { type: 'array', items: { type: 'object' } } },
|
|
1540
|
-
},
|
|
1541
|
-
},
|
|
1542
|
-
},
|
|
1543
|
-
},
|
|
1544
|
-
},
|
|
1545
|
-
},
|
|
1546
|
-
}
|
|
1547
|
-
const { content: result } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1548
|
-
// With multiple list ops, key factory uses operationId-derived keys, NOT generic 'list:'
|
|
1549
|
-
expect(result).toContain('export const itemKeys')
|
|
1550
|
-
expect(result).not.toContain('list:')
|
|
1551
|
-
// Both operations should appear as named entries
|
|
1552
|
-
expect(result).toContain('listItems:')
|
|
1553
|
-
expect(result).toContain('searchItems:')
|
|
1554
|
-
})
|
|
1555
|
-
})
|
|
1556
|
-
|
|
1557
|
-
// ── Bug #105: cache key collision ─────────────────────────────────────────────
|
|
1558
|
-
|
|
1559
|
-
describe('Bug #105: cache key collision when multiple GET ops share same path param', () => {
|
|
1560
|
-
const specWithMultipleDetailOps: OpenAPIV3_1.Document = {
|
|
1561
|
-
openapi: '3.1.0',
|
|
1562
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1563
|
-
paths: {
|
|
1564
|
-
'/items/{id}': {
|
|
1565
|
-
get: {
|
|
1566
|
-
operationId: 'getItemById',
|
|
1567
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1568
|
-
responses: { '200': { description: 'ok' } },
|
|
1569
|
-
},
|
|
1570
|
-
},
|
|
1571
|
-
'/items/{id}/usage': {
|
|
1572
|
-
get: {
|
|
1573
|
-
operationId: 'getItemUsage',
|
|
1574
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1575
|
-
responses: { '200': { description: 'ok' } },
|
|
1576
|
-
},
|
|
1577
|
-
},
|
|
1578
|
-
},
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
const { content: multiDetailResult } = generateHooks(specWithMultipleDetailOps, {
|
|
1582
|
-
staleTime: 0,
|
|
1583
|
-
gcTime: 0,
|
|
1584
|
-
})
|
|
1585
|
-
|
|
1586
|
-
it('both operations have distinct named entries in the key factory', () => {
|
|
1587
|
-
expect(multiDetailResult).toContain('getItemById:')
|
|
1588
|
-
expect(multiDetailResult).toContain('getItemUsage:')
|
|
1589
|
-
})
|
|
1590
|
-
|
|
1591
|
-
it('getItemById key includes operation name as distinguishing segment', () => {
|
|
1592
|
-
// Must be ['items', 'getItemById', id] — not ['items', id] which would collide
|
|
1593
|
-
expect(multiDetailResult).toContain("'items', 'getItemById', id")
|
|
1594
|
-
})
|
|
1595
|
-
|
|
1596
|
-
it('getItemUsage key includes operation name as distinguishing segment', () => {
|
|
1597
|
-
expect(multiDetailResult).toContain("'items', 'getItemUsage', id")
|
|
1598
|
-
})
|
|
1599
|
-
|
|
1600
|
-
it('the two key arrays are structurally distinct (no collision)', () => {
|
|
1601
|
-
const byIdLine = multiDetailResult.match(/getItemById:.*?as const/)
|
|
1602
|
-
const usageLine = multiDetailResult.match(/getItemUsage:.*?as const/)
|
|
1603
|
-
expect(byIdLine).toBeTruthy()
|
|
1604
|
-
expect(usageLine).toBeTruthy()
|
|
1605
|
-
expect(byIdLine![0]).not.toBe(usageLine![0])
|
|
1606
|
-
})
|
|
1607
|
-
|
|
1608
|
-
it('single detail op still uses canonical ["resource", id] key (no breaking change)', () => {
|
|
1609
|
-
const specSingleDetail: OpenAPIV3_1.Document = {
|
|
1610
|
-
openapi: '3.1.0',
|
|
1611
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1612
|
-
paths: {
|
|
1613
|
-
'/items/{id}': {
|
|
1614
|
-
get: {
|
|
1615
|
-
operationId: 'getItemById',
|
|
1616
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1617
|
-
responses: { '200': { description: 'ok' } },
|
|
1618
|
-
},
|
|
1619
|
-
},
|
|
1620
|
-
},
|
|
1621
|
-
}
|
|
1622
|
-
const { content: result } = generateHooks(specSingleDetail, { staleTime: 0, gcTime: 0 })
|
|
1623
|
-
// Single detail op → 'detail' key → ['items', id] unchanged
|
|
1624
|
-
expect(result).toContain('detail:')
|
|
1625
|
-
expect(result).toContain("'items', id")
|
|
1626
|
-
expect(result).not.toContain("'items', 'detail', id")
|
|
1627
|
-
})
|
|
1628
|
-
})
|
|
1629
|
-
|
|
1630
|
-
// ── Bug: path segments with hyphens produce invalid identifiers ───────────────
|
|
1631
|
-
|
|
1632
|
-
describe('generateHooks — path segment sanitization in deriveOperationName', () => {
|
|
1633
|
-
it('/api-keys (no operationId) → createApiKeys (not createApi-keys)', () => {
|
|
1634
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1635
|
-
openapi: '3.1.0',
|
|
1636
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1637
|
-
paths: {
|
|
1638
|
-
'/api-keys': {
|
|
1639
|
-
post: {
|
|
1640
|
-
// no operationId — forces deriveOperationName
|
|
1641
|
-
responses: { '201': { description: 'created' } },
|
|
1642
|
-
},
|
|
1643
|
-
},
|
|
1644
|
-
},
|
|
1645
|
-
}
|
|
1646
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1647
|
-
expect(content).toContain('createApiKeys')
|
|
1648
|
-
expect(content).not.toContain('createApi-keys')
|
|
1649
|
-
})
|
|
1650
|
-
|
|
1651
|
-
it('GET /api-keys → useGetApiKeys (not useGetApi-keys)', () => {
|
|
1652
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1653
|
-
openapi: '3.1.0',
|
|
1654
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1655
|
-
paths: {
|
|
1656
|
-
'/api-keys': {
|
|
1657
|
-
get: {
|
|
1658
|
-
responses: { '200': { description: 'ok' } },
|
|
1659
|
-
},
|
|
1660
|
-
},
|
|
1661
|
-
},
|
|
1662
|
-
}
|
|
1663
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1664
|
-
expect(content).toContain('export function useGetApiKeys')
|
|
1665
|
-
expect(content).not.toContain('useGetApi-keys')
|
|
1666
|
-
})
|
|
1667
|
-
|
|
1668
|
-
it('path param {api-key-id} → ByApiKeyId (not ByApi-key-id)', () => {
|
|
1669
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1670
|
-
openapi: '3.1.0',
|
|
1671
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1672
|
-
paths: {
|
|
1673
|
-
'/api-keys/{api-key-id}': {
|
|
1674
|
-
get: {
|
|
1675
|
-
parameters: [
|
|
1676
|
-
{ name: 'api-key-id', in: 'path', required: true, schema: { type: 'string' } },
|
|
1677
|
-
],
|
|
1678
|
-
responses: { '200': { description: 'ok' } },
|
|
1679
|
-
},
|
|
1680
|
-
},
|
|
1681
|
-
},
|
|
1682
|
-
}
|
|
1683
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1684
|
-
expect(content).toContain('GetApiKeysByApiKeyId')
|
|
1685
|
-
expect(content).not.toContain('Api-key')
|
|
1686
|
-
})
|
|
1687
|
-
})
|
|
1688
|
-
|
|
1689
|
-
// ── Bug: AWS action-style resource names with special chars ────────────────────
|
|
1690
|
-
|
|
1691
|
-
describe('generateHooks — toKeyFactoryName sanitizes special chars', () => {
|
|
1692
|
-
it('resource "#Action=ActivateType" produces valid key factory name', () => {
|
|
1693
|
-
// AWS-style paths use query params embedded in the path segment
|
|
1694
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1695
|
-
openapi: '3.1.0',
|
|
1696
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1697
|
-
paths: {
|
|
1698
|
-
'/#Action=ActivateType': {
|
|
1699
|
-
post: {
|
|
1700
|
-
operationId: 'ActivateType',
|
|
1701
|
-
responses: { '200': { description: 'ok' } },
|
|
1702
|
-
},
|
|
1703
|
-
},
|
|
1704
|
-
},
|
|
1705
|
-
}
|
|
1706
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1707
|
-
// The key factory name must be a valid JS identifier — no #, =, & chars
|
|
1708
|
-
expect(content).not.toContain('#Action=ActivateTypeKeys')
|
|
1709
|
-
expect(content).not.toMatch(/export const [^a-zA-Z_$]/)
|
|
1710
|
-
})
|
|
1711
|
-
|
|
1712
|
-
it('path param with hyphens like {attachment-publicUUID} sanitized in key factory', () => {
|
|
1713
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1714
|
-
openapi: '3.1.0',
|
|
1715
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1716
|
-
paths: {
|
|
1717
|
-
'/attachments/{attachment-publicUUID}': {
|
|
1718
|
-
get: {
|
|
1719
|
-
operationId: 'getAttachment',
|
|
1720
|
-
parameters: [
|
|
1721
|
-
{
|
|
1722
|
-
name: 'attachment-publicUUID',
|
|
1723
|
-
in: 'path',
|
|
1724
|
-
required: true,
|
|
1725
|
-
schema: { type: 'string' },
|
|
1726
|
-
},
|
|
1727
|
-
],
|
|
1728
|
-
responses: { '200': { description: 'ok' } },
|
|
1729
|
-
},
|
|
1730
|
-
},
|
|
1731
|
-
},
|
|
1732
|
-
}
|
|
1733
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1734
|
-
// The path param name must be sanitized to a valid JS identifier
|
|
1735
|
-
expect(content).not.toContain('attachment-publicUUID:')
|
|
1736
|
-
expect(content).not.toContain('attachment-publicUUID!')
|
|
1737
|
-
// The sanitized form attachmentPublicUUID should appear instead
|
|
1738
|
-
expect(content).toContain('attachmentPublicUUID')
|
|
1739
|
-
})
|
|
1740
|
-
})
|
|
1741
|
-
|
|
1742
|
-
// ── Bug #106: ...options spread overwrites auto-invalidation onSuccess ────────
|
|
1743
|
-
|
|
1744
|
-
describe('Bug #106: ...options spread must not overwrite auto-invalidation onSuccess', () => {
|
|
1745
|
-
const specWithAutoInvalidate: OpenAPIV3_1.Document = {
|
|
1746
|
-
openapi: '3.1.0',
|
|
1747
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1748
|
-
paths: {
|
|
1749
|
-
'/items': {
|
|
1750
|
-
get: {
|
|
1751
|
-
operationId: 'listItems',
|
|
1752
|
-
responses: { '200': { description: 'ok' } },
|
|
1753
|
-
},
|
|
1754
|
-
post: {
|
|
1755
|
-
operationId: 'createItem',
|
|
1756
|
-
requestBody: {
|
|
1757
|
-
required: true,
|
|
1758
|
-
content: { 'application/json': { schema: { $ref: '#/components/schemas/Item' } } },
|
|
1759
|
-
},
|
|
1760
|
-
responses: { '201': { description: 'ok' } },
|
|
1761
|
-
},
|
|
1762
|
-
},
|
|
1763
|
-
'/items/{id}': {
|
|
1764
|
-
delete: {
|
|
1765
|
-
operationId: 'deleteItem',
|
|
1766
|
-
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
|
|
1767
|
-
responses: { '204': { description: 'ok' } },
|
|
1768
|
-
},
|
|
1769
|
-
},
|
|
1770
|
-
},
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
const { content: autoInvalidateResult } = generateHooks(specWithAutoInvalidate, {
|
|
1774
|
-
staleTime: 0,
|
|
1775
|
-
gcTime: 0,
|
|
1776
|
-
autoInvalidate: true,
|
|
1777
|
-
})
|
|
1778
|
-
|
|
1779
|
-
it('...options spread appears BEFORE onSuccess in mutation hook with auto-invalidation', () => {
|
|
1780
|
-
const hookStart = autoInvalidateResult.indexOf('export function useCreateItem')
|
|
1781
|
-
const hookEnd = autoInvalidateResult.indexOf('\n}', hookStart) + 2
|
|
1782
|
-
const hookContent = autoInvalidateResult.slice(hookStart, hookEnd)
|
|
1783
|
-
|
|
1784
|
-
const spreadIdx = hookContent.indexOf('...options,')
|
|
1785
|
-
const onSuccessIdx = hookContent.indexOf('onSuccess:')
|
|
1786
|
-
|
|
1787
|
-
expect(spreadIdx).toBeGreaterThan(-1)
|
|
1788
|
-
expect(onSuccessIdx).toBeGreaterThan(-1)
|
|
1789
|
-
// spread must come BEFORE onSuccess so the generated onSuccess always wins
|
|
1790
|
-
expect(spreadIdx).toBeLessThan(onSuccessIdx)
|
|
1791
|
-
})
|
|
1792
|
-
|
|
1793
|
-
it('generated onSuccess still composes options.onSuccess so callers can react to success', () => {
|
|
1794
|
-
expect(autoInvalidateResult).toContain('options?.onSuccess?.(...args)')
|
|
1795
|
-
})
|
|
1796
|
-
|
|
1797
|
-
it('mutation hook without auto-invalidation is unaffected (no onSuccess generated)', () => {
|
|
1798
|
-
const { content: plain } = generateHooks(specWithAutoInvalidate, {
|
|
1799
|
-
staleTime: 0,
|
|
1800
|
-
gcTime: 0,
|
|
1801
|
-
autoInvalidate: false,
|
|
1802
|
-
})
|
|
1803
|
-
const hookStart = plain.indexOf('export function useCreateItem')
|
|
1804
|
-
const hookEnd = plain.indexOf('\n}', hookStart) + 2
|
|
1805
|
-
const hookContent = plain.slice(hookStart, hookEnd)
|
|
1806
|
-
expect(hookContent).not.toContain('onSuccess:')
|
|
1807
|
-
})
|
|
1808
|
-
})
|
|
1809
|
-
|
|
1810
|
-
describe('coverage: spec without paths property — generates empty output', () => {
|
|
1811
|
-
it('spec with paths=undefined produces a valid but empty hooks file', () => {
|
|
1812
|
-
// Covers the `if (paths !== undefined)` false branch in generateHooks (line 542)
|
|
1813
|
-
const spec = {
|
|
1814
|
-
openapi: '3.1.0',
|
|
1815
|
-
info: { title: 'Empty', version: '1.0.0' },
|
|
1816
|
-
// no paths property
|
|
1817
|
-
} as OpenAPIV3_1.Document
|
|
1818
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1819
|
-
expect(content).toBeDefined()
|
|
1820
|
-
// No hooks generated — just the header boilerplate
|
|
1821
|
-
expect(content).not.toContain('export function use')
|
|
1822
|
-
})
|
|
1823
|
-
})
|
|
1824
|
-
|
|
1825
|
-
describe('coverage: requestBody with no content — hasBody but no bodyTypeName', () => {
|
|
1826
|
-
it('POST with requestBody missing content property produces mutation without typed body', () => {
|
|
1827
|
-
// Covers `if (content === undefined) return { hasBody: true, bodyTypeName: undefined }` (line 112)
|
|
1828
|
-
const spec: OpenAPIV3_1.Document = {
|
|
1829
|
-
openapi: '3.1.0',
|
|
1830
|
-
info: { title: 'Test', version: '1.0.0' },
|
|
1831
|
-
paths: {
|
|
1832
|
-
'/items': {
|
|
1833
|
-
post: {
|
|
1834
|
-
operationId: 'createItem',
|
|
1835
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1836
|
-
requestBody: { required: true } as any, // no content property
|
|
1837
|
-
responses: { '201': { description: 'created' } },
|
|
1838
|
-
},
|
|
1839
|
-
},
|
|
1840
|
-
},
|
|
1841
|
-
}
|
|
1842
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
1843
|
-
expect(content).toContain('export function useCreateItem')
|
|
1844
|
-
})
|
|
1845
|
-
})
|