@codewithagents/openapi-react-query 3.5.1 → 3.6.0
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/cli-args.d.ts +23 -0
- package/dist/cli-args.d.ts.map +1 -0
- package/dist/cli-args.js +37 -0
- package/dist/cli-args.js.map +1 -0
- package/dist/cli.js +25 -17
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- 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,291 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
3
|
-
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { loadConfig, validateConfigPath, validateOutputPath, validateInputPath } from '../config.js'
|
|
6
|
-
|
|
7
|
-
describe('loadConfig', () => {
|
|
8
|
-
let tmpDir: string
|
|
9
|
-
|
|
10
|
-
beforeEach(() => {
|
|
11
|
-
tmpDir = mkdtempSync(join(tmpdir(), 'openapi-react-query-config-'))
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
afterEach(() => {
|
|
15
|
-
rmSync(tmpDir, { recursive: true, force: true })
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
function writeConfig(content: unknown) {
|
|
19
|
-
writeFileSync(join(tmpDir, 'openapi-react-query.config.json'), JSON.stringify(content))
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
it('loads a valid minimal config', async () => {
|
|
23
|
-
writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks' })
|
|
24
|
-
const config = await loadConfig(tmpDir)
|
|
25
|
-
expect(config.input_openapi).toBe('openapi.json')
|
|
26
|
-
expect(config.output).toBe('src/hooks')
|
|
27
|
-
expect(config.stale_time).toBeUndefined()
|
|
28
|
-
expect(config.gc_time).toBeUndefined()
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('loads a full config with all fields', async () => {
|
|
32
|
-
writeConfig({
|
|
33
|
-
input_openapi: 'api/openapi.json',
|
|
34
|
-
output: 'src/generated',
|
|
35
|
-
stale_time: 5000,
|
|
36
|
-
gc_time: 600000,
|
|
37
|
-
})
|
|
38
|
-
const config = await loadConfig(tmpDir)
|
|
39
|
-
expect(config.input_openapi).toBe('api/openapi.json')
|
|
40
|
-
expect(config.output).toBe('src/generated')
|
|
41
|
-
expect(config.stale_time).toBe(5000)
|
|
42
|
-
expect(config.gc_time).toBe(600000)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('throws when config file is missing', async () => {
|
|
46
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('Config file not found')
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('throws when config is not valid JSON', async () => {
|
|
50
|
-
writeFileSync(join(tmpDir, 'openapi-react-query.config.json'), 'not json {{{')
|
|
51
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('not valid JSON')
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
it('throws when config is not an object', async () => {
|
|
55
|
-
writeFileSync(join(tmpDir, 'openapi-react-query.config.json'), '"just a string"')
|
|
56
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('JSON object')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('throws when input_openapi is missing', async () => {
|
|
60
|
-
writeConfig({ output: 'src/hooks' })
|
|
61
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('input_openapi')
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('throws when output is missing', async () => {
|
|
65
|
-
writeConfig({ input_openapi: 'openapi.json' })
|
|
66
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('output')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('throws when stale_time is not a number', async () => {
|
|
70
|
-
writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', stale_time: 'fast' })
|
|
71
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('stale_time')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
it('throws when gc_time is not a number', async () => {
|
|
75
|
-
writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', gc_time: true })
|
|
76
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('gc_time')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
it('ignores unknown config fields', async () => {
|
|
80
|
-
writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', unknown_field: 'ignored' })
|
|
81
|
-
const config = await loadConfig(tmpDir)
|
|
82
|
-
expect(config.input_openapi).toBe('openapi.json')
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('accepts a config loaded via explicit configPath', async () => {
|
|
86
|
-
const configFile = join(tmpDir, 'custom.config.json')
|
|
87
|
-
writeFileSync(
|
|
88
|
-
configFile,
|
|
89
|
-
JSON.stringify({ input_openapi: 'openapi.json', output: 'src/hooks' })
|
|
90
|
-
)
|
|
91
|
-
const config = await loadConfig(tmpDir, configFile)
|
|
92
|
-
expect(config.input_openapi).toBe('openapi.json')
|
|
93
|
-
expect(config.output).toBe('src/hooks')
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
it('rejects explicit configPath that is not .json', async () => {
|
|
97
|
-
const configFile = join(tmpDir, 'config.ts')
|
|
98
|
-
writeFileSync(
|
|
99
|
-
configFile,
|
|
100
|
-
JSON.stringify({ input_openapi: 'openapi.json', output: 'src/hooks' })
|
|
101
|
-
)
|
|
102
|
-
await expect(loadConfig(tmpDir, configFile)).rejects.toThrow('Config file must be a .json file')
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('throws when suspense is not a boolean', async () => {
|
|
106
|
-
writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', suspense: 'yes' })
|
|
107
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('"suspense" must be a boolean')
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('throws when auto_invalidate is not a boolean', async () => {
|
|
111
|
-
writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', auto_invalidate: 'yes' })
|
|
112
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('"auto_invalidate" must be a boolean')
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('throws when overrides is not an object', async () => {
|
|
116
|
-
writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', overrides: 'flat' })
|
|
117
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('"overrides" must be an object')
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('throws when overrides is an array', async () => {
|
|
121
|
-
writeConfig({ input_openapi: 'openapi.json', output: 'src/hooks', overrides: [] })
|
|
122
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('"overrides" must be an object')
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('throws when an override entry is not an object', async () => {
|
|
126
|
-
writeConfig({
|
|
127
|
-
input_openapi: 'openapi.json',
|
|
128
|
-
output: 'src/hooks',
|
|
129
|
-
overrides: { pets: 'wrong' },
|
|
130
|
-
})
|
|
131
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('"overrides.pets" must be an object')
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
it('throws when override stale_time is not a number', async () => {
|
|
135
|
-
writeConfig({
|
|
136
|
-
input_openapi: 'openapi.json',
|
|
137
|
-
output: 'src/hooks',
|
|
138
|
-
overrides: { pets: { stale_time: 'fast' } },
|
|
139
|
-
})
|
|
140
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('"overrides.pets.stale_time" must be a number')
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
it('throws when override gc_time is not a number', async () => {
|
|
144
|
-
writeConfig({
|
|
145
|
-
input_openapi: 'openapi.json',
|
|
146
|
-
output: 'src/hooks',
|
|
147
|
-
overrides: { pets: { gc_time: true } },
|
|
148
|
-
})
|
|
149
|
-
await expect(loadConfig(tmpDir)).rejects.toThrow('"overrides.pets.gc_time" must be a number')
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it('loads valid overrides', async () => {
|
|
153
|
-
writeConfig({
|
|
154
|
-
input_openapi: 'openapi.json',
|
|
155
|
-
output: 'src/hooks',
|
|
156
|
-
overrides: { pets: { stale_time: 1000, gc_time: 5000 } },
|
|
157
|
-
})
|
|
158
|
-
const config = await loadConfig(tmpDir)
|
|
159
|
-
expect(config.overrides).toEqual({ pets: { stale_time: 1000, gc_time: 5000 } })
|
|
160
|
-
})
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
describe('config security validation', () => {
|
|
164
|
-
describe('validateConfigPath', () => {
|
|
165
|
-
it('rejects non-.json config file extension', () => {
|
|
166
|
-
expect(() => validateConfigPath('/project/config.ts')).toThrow(
|
|
167
|
-
'Config file must be a .json file'
|
|
168
|
-
)
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('rejects .yaml extension', () => {
|
|
172
|
-
expect(() => validateConfigPath('/project/config.yaml')).toThrow(
|
|
173
|
-
'Config file must be a .json file'
|
|
174
|
-
)
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
it('accepts .json extension', () => {
|
|
178
|
-
expect(() => validateConfigPath('/project/config.json')).not.toThrow()
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('accepts nested .json path', () => {
|
|
182
|
-
expect(() => validateConfigPath('/Users/someone/project/my-tool.config.json')).not.toThrow()
|
|
183
|
-
})
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
describe('validateOutputPath', () => {
|
|
187
|
-
it('rejects output path in /etc', () => {
|
|
188
|
-
expect(() => validateOutputPath('/etc/generated')).toThrow('system directory')
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
it('rejects output path in /usr', () => {
|
|
192
|
-
expect(() => validateOutputPath('/usr/local/generated')).toThrow('system directory')
|
|
193
|
-
})
|
|
194
|
-
|
|
195
|
-
it('rejects output path in /bin', () => {
|
|
196
|
-
expect(() => validateOutputPath('/bin/generated')).toThrow('system directory')
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
it('rejects output path in /sys', () => {
|
|
200
|
-
expect(() => validateOutputPath('/sys/something')).toThrow('system directory')
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
it('rejects output path in /proc', () => {
|
|
204
|
-
expect(() => validateOutputPath('/proc/1/generated')).toThrow('system directory')
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it('rejects output path in /dev', () => {
|
|
208
|
-
expect(() => validateOutputPath('/dev/null')).toThrow('system directory')
|
|
209
|
-
})
|
|
210
|
-
|
|
211
|
-
it('rejects output path in /boot', () => {
|
|
212
|
-
expect(() => validateOutputPath('/boot/generated')).toThrow('system directory')
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
it('rejects output path that is exactly /run', () => {
|
|
216
|
-
expect(() => validateOutputPath('/run')).toThrow('system directory')
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
it('rejects output path under /run/', () => {
|
|
220
|
-
expect(() => validateOutputPath('/run/lock/something')).toThrow('system directory')
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
it('does NOT reject /runner/_work/... (CI runner path)', () => {
|
|
224
|
-
expect(() => validateOutputPath('/runner/_work/project/src/generated')).not.toThrow()
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
it('accepts normal project-relative resolved path', () => {
|
|
228
|
-
expect(() => validateOutputPath('/Users/someone/project/generated')).not.toThrow()
|
|
229
|
-
})
|
|
230
|
-
|
|
231
|
-
it('accepts absolute path within home directory', () => {
|
|
232
|
-
expect(() => validateOutputPath('/Users/someone/myproject/src/hooks')).not.toThrow()
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
it('accepts dist/api style path', () => {
|
|
236
|
-
expect(() => validateOutputPath('/home/user/project/dist/hooks')).not.toThrow()
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
it('accepts GitHub Actions home runner output path', () => {
|
|
240
|
-
expect(() =>
|
|
241
|
-
validateOutputPath('/home/runner/work/my-repo/my-repo/src/generated')
|
|
242
|
-
).not.toThrow()
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
it('accepts GitLab runner build output path', () => {
|
|
246
|
-
expect(() =>
|
|
247
|
-
validateOutputPath('/var/lib/gitlab-runner/builds/project/src/generated')
|
|
248
|
-
).not.toThrow()
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
it('accepts common CI workspace output path', () => {
|
|
252
|
-
expect(() => validateOutputPath('/workspace/project/src/generated')).not.toThrow()
|
|
253
|
-
})
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
describe('validateInputPath', () => {
|
|
257
|
-
it('rejects input spec from /proc', () => {
|
|
258
|
-
expect(() => validateInputPath('/proc/1/fd/0')).toThrow('system directory')
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
it('rejects input spec from /etc', () => {
|
|
262
|
-
expect(() => validateInputPath('/etc/passwd')).toThrow('system directory')
|
|
263
|
-
})
|
|
264
|
-
|
|
265
|
-
it('rejects input spec from /dev', () => {
|
|
266
|
-
expect(() => validateInputPath('/dev/random')).toThrow('system directory')
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
it('rejects input spec from /usr', () => {
|
|
270
|
-
expect(() => validateInputPath('/usr/share/openapi.json')).toThrow('system directory')
|
|
271
|
-
})
|
|
272
|
-
|
|
273
|
-
it('accepts input spec in home directory', () => {
|
|
274
|
-
expect(() => validateInputPath('/Users/someone/project/openapi.json')).not.toThrow()
|
|
275
|
-
})
|
|
276
|
-
|
|
277
|
-
it('accepts input spec in /tmp', () => {
|
|
278
|
-
expect(() => validateInputPath('/tmp/openapi.json')).not.toThrow()
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
it('accepts GitHub Actions home runner input path', () => {
|
|
282
|
-
expect(() =>
|
|
283
|
-
validateInputPath('/home/runner/work/my-repo/my-repo/spec/api.json')
|
|
284
|
-
).not.toThrow()
|
|
285
|
-
})
|
|
286
|
-
|
|
287
|
-
it('accepts common CI workspace input path', () => {
|
|
288
|
-
expect(() => validateInputPath('/workspace/project/spec/openapi.json')).not.toThrow()
|
|
289
|
-
})
|
|
290
|
-
})
|
|
291
|
-
})
|
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
import fc from 'fast-check'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import type { OpenAPIV3_1 } from 'openapi-types'
|
|
4
|
-
import { generateHooks } from '../plugins/hooks.js'
|
|
5
|
-
|
|
6
|
-
// ── Arbitrary generators ────────────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
/** Valid JS identifier: starts with letter, letters+digits, length 3–12 */
|
|
9
|
-
const arbOperationId = fc.stringMatching(/^[a-z][a-zA-Z0-9]{2,11}$/)
|
|
10
|
-
|
|
11
|
-
/** Valid JS identifier for path params: length 2–8 */
|
|
12
|
-
const arbPathParam = fc.stringMatching(/^[a-z][a-z0-9]{1,7}$/)
|
|
13
|
-
|
|
14
|
-
/** Query param object */
|
|
15
|
-
const arbQueryParam = fc.record({
|
|
16
|
-
name: arbPathParam,
|
|
17
|
-
required: fc.boolean(),
|
|
18
|
-
})
|
|
19
|
-
|
|
20
|
-
/** Realistic resource names */
|
|
21
|
-
const RESOURCES = [
|
|
22
|
-
'items',
|
|
23
|
-
'tasks',
|
|
24
|
-
'users',
|
|
25
|
-
'projects',
|
|
26
|
-
'orders',
|
|
27
|
-
'reports',
|
|
28
|
-
'documents',
|
|
29
|
-
'records',
|
|
30
|
-
]
|
|
31
|
-
|
|
32
|
-
/** Generates realistic path strings */
|
|
33
|
-
const arbPath: fc.Arbitrary<string> = fc.oneof(
|
|
34
|
-
// /resources (no path params)
|
|
35
|
-
fc.constantFrom(...RESOURCES).map((r) => `/${r}`),
|
|
36
|
-
// /resources/{id} (1 path param)
|
|
37
|
-
fc.tuple(fc.constantFrom(...RESOURCES), arbPathParam).map(([r, p]) => `/${r}/{${p}}`),
|
|
38
|
-
// /resources/{p1}/sub/{p2} (2 path params)
|
|
39
|
-
fc
|
|
40
|
-
.tuple(fc.constantFrom(...RESOURCES), arbPathParam, fc.constantFrom(...RESOURCES), arbPathParam)
|
|
41
|
-
.map(([r, p1, sub, p2]) => `/${r}/{${p1}}/${sub}/{${p2}}`)
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'
|
|
45
|
-
|
|
46
|
-
const arbHttpMethod: fc.Arbitrary<HttpMethod> = fc.constantFrom(
|
|
47
|
-
'get',
|
|
48
|
-
'post',
|
|
49
|
-
'put',
|
|
50
|
-
'patch',
|
|
51
|
-
'delete'
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
function extractPathParams(path: string): string[] {
|
|
55
|
-
const matches = path.match(/\{([^}]+)\}/g)
|
|
56
|
-
if (matches === null) return []
|
|
57
|
-
return matches.map((m) => m.slice(1, -1))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface ArbOp {
|
|
61
|
-
operationId: string
|
|
62
|
-
method: HttpMethod
|
|
63
|
-
path: string
|
|
64
|
-
pathParams: string[]
|
|
65
|
-
hasBody: boolean
|
|
66
|
-
queryParams: { name: string; required: boolean }[]
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Generates a single operation object */
|
|
70
|
-
const arbOperation: fc.Arbitrary<ArbOp> = fc
|
|
71
|
-
.tuple(
|
|
72
|
-
arbOperationId,
|
|
73
|
-
arbHttpMethod,
|
|
74
|
-
arbPath,
|
|
75
|
-
fc.array(arbQueryParam, { minLength: 0, maxLength: 3 })
|
|
76
|
-
)
|
|
77
|
-
.map(([operationId, method, path, queryParams]) => {
|
|
78
|
-
const pathParams = extractPathParams(path)
|
|
79
|
-
const hasBody = method === 'post' || method === 'put' || method === 'patch'
|
|
80
|
-
return { operationId, method, path, pathParams, hasBody, queryParams }
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
/** Build a valid OpenAPI 3.1 document from ArbOp list */
|
|
84
|
-
function buildSpec(ops: ArbOp[]): OpenAPIV3_1.Document {
|
|
85
|
-
const paths: OpenAPIV3_1.PathsObject = {}
|
|
86
|
-
for (const op of ops) {
|
|
87
|
-
if (!paths[op.path]) paths[op.path] = {}
|
|
88
|
-
const parameters = [
|
|
89
|
-
...op.pathParams.map((p) => ({
|
|
90
|
-
name: p,
|
|
91
|
-
in: 'path' as const,
|
|
92
|
-
required: true,
|
|
93
|
-
schema: { type: 'string' as const },
|
|
94
|
-
})),
|
|
95
|
-
...op.queryParams.map((q) => ({
|
|
96
|
-
name: q.name,
|
|
97
|
-
in: 'query' as const,
|
|
98
|
-
required: q.required,
|
|
99
|
-
schema: { type: 'string' as const },
|
|
100
|
-
})),
|
|
101
|
-
]
|
|
102
|
-
;(paths[op.path] as Record<string, unknown>)[op.method] = {
|
|
103
|
-
operationId: op.operationId,
|
|
104
|
-
parameters,
|
|
105
|
-
...(op.hasBody
|
|
106
|
-
? {
|
|
107
|
-
requestBody: {
|
|
108
|
-
required: true,
|
|
109
|
-
content: { 'application/json': { schema: { type: 'object' } } },
|
|
110
|
-
},
|
|
111
|
-
}
|
|
112
|
-
: {}),
|
|
113
|
-
responses: { '200': { description: 'ok' } },
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths }
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Generate a spec from 1–8 operations with unique operationIds.
|
|
121
|
-
* Ensures no duplicate (path, method) pairs and unique operationIds.
|
|
122
|
-
*/
|
|
123
|
-
const arbSpec: fc.Arbitrary<OpenAPIV3_1.Document> = fc
|
|
124
|
-
.array(arbOperation, { minLength: 1, maxLength: 8 })
|
|
125
|
-
.map((ops) => {
|
|
126
|
-
// Deduplicate by (path, method) — last one wins
|
|
127
|
-
const seen = new Map<string, ArbOp>()
|
|
128
|
-
for (const op of ops) {
|
|
129
|
-
seen.set(`${op.path}:${op.method}`, op)
|
|
130
|
-
}
|
|
131
|
-
const deduped = Array.from(seen.values())
|
|
132
|
-
|
|
133
|
-
// Make operationIds unique by appending index
|
|
134
|
-
const uniqueOps = deduped.map((op, i) => ({ ...op, operationId: `${op.operationId}${i}` }))
|
|
135
|
-
return buildSpec(uniqueOps)
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
// ── Property tests ──────────────────────────────────────────────────────────
|
|
139
|
-
|
|
140
|
-
describe('property-based tests — generateHooks invariants', () => {
|
|
141
|
-
// Property 1: generator never throws
|
|
142
|
-
it('never throws on any generated spec', () => {
|
|
143
|
-
fc.assert(
|
|
144
|
-
fc.property(arbSpec, (spec) => {
|
|
145
|
-
expect(() => generateHooks(spec, { staleTime: 0, gcTime: 0 })).not.toThrow()
|
|
146
|
-
}),
|
|
147
|
-
{ numRuns: 500 }
|
|
148
|
-
)
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
// Property 2: every GET produces a useQuery hook
|
|
152
|
-
it('every GET produces a useQuery hook named use + PascalCase(operationId)', () => {
|
|
153
|
-
fc.assert(
|
|
154
|
-
fc.property(arbSpec, (spec) => {
|
|
155
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
156
|
-
for (const pathItem of Object.values(spec.paths ?? {})) {
|
|
157
|
-
const op = (pathItem as Record<string, unknown>)['get'] as
|
|
158
|
-
| { operationId?: string }
|
|
159
|
-
| undefined
|
|
160
|
-
if (!op?.operationId) continue
|
|
161
|
-
const hookName = 'use' + op.operationId[0]!.toUpperCase() + op.operationId.slice(1)
|
|
162
|
-
expect(content).toContain(`export function ${hookName}(`)
|
|
163
|
-
}
|
|
164
|
-
}),
|
|
165
|
-
{ numRuns: 300 }
|
|
166
|
-
)
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
// Property 3: every mutation produces a useMutation hook
|
|
170
|
-
it('every POST/PUT/PATCH/DELETE produces a useMutation hook', () => {
|
|
171
|
-
fc.assert(
|
|
172
|
-
fc.property(arbSpec, (spec) => {
|
|
173
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
174
|
-
for (const pathItem of Object.values(spec.paths ?? {})) {
|
|
175
|
-
for (const method of ['post', 'put', 'patch', 'delete'] as const) {
|
|
176
|
-
const op = (pathItem as Record<string, unknown>)[method] as
|
|
177
|
-
| { operationId?: string }
|
|
178
|
-
| undefined
|
|
179
|
-
if (!op?.operationId) continue
|
|
180
|
-
const hookName = 'use' + op.operationId[0]!.toUpperCase() + op.operationId.slice(1)
|
|
181
|
-
expect(content).toContain(`export function ${hookName}(`)
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}),
|
|
185
|
-
{ numRuns: 300 }
|
|
186
|
-
)
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
// Property 4: key factory always defines all() when any GET exists
|
|
190
|
-
// NOTE: The invariant checks for the definition `all:` in the key factory, not a call site `.all()`.
|
|
191
|
-
// A call to `.all()` only appears in mutation onSuccess handlers (autoInvalidate mode); a detail-only
|
|
192
|
-
// spec with no mutations correctly defines `all:` without ever calling it.
|
|
193
|
-
it('key factory defines all() method whenever there is at least one GET operation', () => {
|
|
194
|
-
fc.assert(
|
|
195
|
-
fc.property(arbSpec, (spec) => {
|
|
196
|
-
const hasGet = Object.values(spec.paths ?? {}).some(
|
|
197
|
-
(p) => (p as Record<string, unknown>)?.['get'] !== undefined
|
|
198
|
-
)
|
|
199
|
-
if (!hasGet) return // skip specs with no GET
|
|
200
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
201
|
-
// The key factory always includes an `all:` method definition
|
|
202
|
-
expect(content).toContain('all:')
|
|
203
|
-
}),
|
|
204
|
-
{ numRuns: 300 }
|
|
205
|
-
)
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
// Property 5: hooks with 2+ path params always use && in enabled guard
|
|
209
|
-
it('detail hooks with 2+ path params use && in enabled guard', () => {
|
|
210
|
-
fc.assert(
|
|
211
|
-
fc.property(arbSpec, (spec) => {
|
|
212
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
213
|
-
for (const [_path, pathItem] of Object.entries(spec.paths ?? {})) {
|
|
214
|
-
const op = (pathItem as Record<string, unknown>)['get'] as
|
|
215
|
-
| { operationId?: string; parameters?: Array<{ in: string }> }
|
|
216
|
-
| undefined
|
|
217
|
-
if (!op?.operationId) continue
|
|
218
|
-
const params = (op.parameters ?? []).filter((p) => p.in === 'path')
|
|
219
|
-
if (params.length < 2) continue
|
|
220
|
-
// The enabled guard should AND all params together
|
|
221
|
-
expect(content).toContain('&&')
|
|
222
|
-
}
|
|
223
|
-
}),
|
|
224
|
-
{ numRuns: 200 }
|
|
225
|
-
)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
// Property 6: with autoInvalidate, mutations only reference key factories that are generated.
|
|
229
|
-
// Previously buggy: the generator emitted `${resource}Keys.all()` even for mutation-only
|
|
230
|
-
// resources (no GET ops), producing a ReferenceError because the key factory was never defined.
|
|
231
|
-
// Fixed by gating invalidateInfo on resourceToGetOps.has(resource).
|
|
232
|
-
it('with autoInvalidate, mutation hooks only reference key factories that are generated', () => {
|
|
233
|
-
fc.assert(
|
|
234
|
-
fc.property(arbSpec, (spec) => {
|
|
235
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0, autoInvalidate: true })
|
|
236
|
-
|
|
237
|
-
// Collect all key factory names that ARE generated (only for resources with GET ops)
|
|
238
|
-
const resourcesWithGets = new Set<string>()
|
|
239
|
-
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
|
|
240
|
-
const op = (pathItem as Record<string, unknown>)['get']
|
|
241
|
-
if (op === undefined) continue
|
|
242
|
-
// Extract primary resource (mirrors primaryResource() function)
|
|
243
|
-
const stripped = path.replace(/^\/api\/v\d+\//, '').replace(/^\//, '')
|
|
244
|
-
const firstSegment = (stripped.split('/')[0] ?? 'resource').replace(/[{}]/g, '')
|
|
245
|
-
resourcesWithGets.add(firstSegment)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// For each resource that appears in mutation ops but NOT in GET ops,
|
|
249
|
-
// a key factory reference in onSuccess is invalid (undefined reference)
|
|
250
|
-
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
|
|
251
|
-
for (const method of ['post', 'put', 'patch', 'delete'] as const) {
|
|
252
|
-
const op = (pathItem as Record<string, unknown>)[method] as
|
|
253
|
-
| { operationId?: string }
|
|
254
|
-
| undefined
|
|
255
|
-
if (!op?.operationId) continue
|
|
256
|
-
|
|
257
|
-
const stripped = path.replace(/^\/api\/v\d+\//, '').replace(/^\//, '')
|
|
258
|
-
const firstSegment = (stripped.split('/')[0] ?? 'resource').replace(/[{}]/g, '')
|
|
259
|
-
|
|
260
|
-
if (!resourcesWithGets.has(firstSegment)) {
|
|
261
|
-
// This mutation is on a resource with no GET → generator will still emit
|
|
262
|
-
// `${factoryName}.all()` in the mutation hook, but the factory is never defined
|
|
263
|
-
const hookName = 'use' + op.operationId[0]!.toUpperCase() + op.operationId.slice(1)
|
|
264
|
-
const hookStart = content.indexOf(`export function ${hookName}(`)
|
|
265
|
-
if (hookStart === -1) continue
|
|
266
|
-
const hookEnd = content.indexOf('\n}', hookStart) + 2
|
|
267
|
-
const hookContent = content.slice(hookStart, hookEnd)
|
|
268
|
-
|
|
269
|
-
// The generated hook will reference factoryName (e.g. itemKeys)
|
|
270
|
-
// but that factory is never exported — check if the factory is defined
|
|
271
|
-
const camel = firstSegment.replace(/[-_](.)/g, (_: string, c: string) =>
|
|
272
|
-
c.toUpperCase()
|
|
273
|
-
)
|
|
274
|
-
const singular = camel.endsWith('s') ? camel.slice(0, -1) : camel
|
|
275
|
-
const factoryName = `${singular}Keys`
|
|
276
|
-
|
|
277
|
-
if (hookContent.includes(`${factoryName}.all()`)) {
|
|
278
|
-
// The key factory reference exists in the hook — verify the factory is exported
|
|
279
|
-
expect(content).toContain(`export const ${factoryName}`)
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}),
|
|
285
|
-
{ numRuns: 200 }
|
|
286
|
-
)
|
|
287
|
-
})
|
|
288
|
-
|
|
289
|
-
// Property 7: generated content always starts with the auto-generated header comment
|
|
290
|
-
it('generated output always starts with the auto-generated header comment', () => {
|
|
291
|
-
fc.assert(
|
|
292
|
-
fc.property(arbSpec, (spec) => {
|
|
293
|
-
const { content } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
294
|
-
expect(content).toMatch(/^\/\/ (This file is auto-generated|Generated)/)
|
|
295
|
-
}),
|
|
296
|
-
{ numRuns: 200 }
|
|
297
|
-
)
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
// Property 8: filename is always hooks.ts
|
|
301
|
-
it('filename is always hooks.ts', () => {
|
|
302
|
-
fc.assert(
|
|
303
|
-
fc.property(arbSpec, (spec) => {
|
|
304
|
-
const { filename } = generateHooks(spec, { staleTime: 0, gcTime: 0 })
|
|
305
|
-
expect(filename).toBe('hooks.ts')
|
|
306
|
-
}),
|
|
307
|
-
{ numRuns: 100 }
|
|
308
|
-
)
|
|
309
|
-
})
|
|
310
|
-
})
|