Skip to content

Commit af9b8db

Browse files
committed
test: validate response by json schema
1 parent af5687f commit af9b8db

File tree

2 files changed

+174
-6
lines changed

2 files changed

+174
-6
lines changed
File renamed without changes.

test/e2e/openapi.test.ts

Lines changed: 174 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import { $fetch, setup } from '@nuxt/test-utils/e2e'
22
import { fileURLToPath } from 'node:url'
33
import { describe, expect, it } from 'vitest'
4+
import { z } from 'zod'
45

56
await setup({ rootDir: fileURLToPath(new URL('../../', import.meta.url)) })
67

78
type OpenApiSpec = {
8-
components?: {
9-
responses?: Record<string, unknown>
10-
schemas?: Record<string, unknown>
9+
components: {
10+
responses?: Record<
11+
string,
12+
{ content?: Record<string, { schema?: z.core.JSONSchema.JSONSchema }> }
13+
>
14+
schemas?: Record<string, z.core.JSONSchema.JSONSchema>
1115
}
1216
paths: Record<
1317
string,
1418
Record<
15-
string,
19+
'delete' | 'get' | 'patch' | 'post' | 'put',
1620
{
1721
description?: string
1822
operationId?: string
@@ -22,7 +26,7 @@ type OpenApiSpec = {
2226
content?: Record<
2327
string,
2428
{
25-
schema?: unknown
29+
schema?: z.core.JSONSchema.JSONSchema
2630
}
2731
>
2832
description?: string
@@ -34,7 +38,7 @@ type OpenApiSpec = {
3438
content?: Record<
3539
string,
3640
{
37-
schema?: unknown
41+
schema?: z.core.JSONSchema.JSONSchema
3842
}
3943
>
4044
description?: string
@@ -251,6 +255,170 @@ describe('openapi metadata validation', () => {
251255
expect(Object.keys(tagsByPath).length).toBeGreaterThan(0)
252256
})
253257

258+
it('should validate actual API responses against OpenAPI schemas', async () => {
259+
const openApiSpec = await $fetch<OpenApiSpec>('/_docs/openapi.json')
260+
261+
const randomNumber = (min: number, max: number) =>
262+
Math.floor(Math.random() * (max - min + 1)) + min
263+
264+
const randomValue = <T>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]
265+
266+
const testSymbols = ['en', 'es', 'fr', 'de', 'it', 'pt', 'nl']
267+
const testCodes = ['E', 'S', 'F', 'D', 'I', 'P', 'O']
268+
269+
// Define test cases for different endpoints with sample parameters
270+
const testCases: Array<{
271+
description: string
272+
openApiPath: string
273+
params?: Record<string, number | string>
274+
}> = [
275+
{
276+
description: 'Bible verse endpoint',
277+
openApiPath: '/api/v1/bible/{symbol}/{book}/{chapter}/{verse}',
278+
params: {
279+
book: randomNumber(1, 66).toString(),
280+
chapter: '1',
281+
symbol: randomValue(testSymbols),
282+
verse: '1'
283+
}
284+
},
285+
{
286+
description: 'Bible books endpoint',
287+
openApiPath: '/api/v1/bible/{symbol}/books',
288+
params: { symbol: randomValue(testSymbols) }
289+
},
290+
{
291+
description: 'Yeartext endpoint',
292+
openApiPath: '/api/v1/wol/yeartext',
293+
params: { wtlocale: randomValue(testCodes), year: new Date().getFullYear() }
294+
},
295+
{
296+
description: 'Meeting schedule endpoint',
297+
openApiPath: '/api/v1/meeting/schedule',
298+
params: { langwritten: randomValue(testCodes) }
299+
}
300+
]
301+
302+
const errors: string[] = []
303+
304+
for (const testCase of testCases) {
305+
try {
306+
// Get the OpenAPI schema for this endpoint
307+
const endpointSpec = openApiSpec.paths[testCase.openApiPath]?.get
308+
if (!endpointSpec) {
309+
errors.push(`${testCase.description}: No OpenAPI spec found for ${testCase.openApiPath}`)
310+
continue
311+
}
312+
313+
const response200 = endpointSpec.responses?.['200']
314+
if (!response200 || !('content' in response200)) {
315+
errors.push(`${testCase.description}: No 200 response schema defined`)
316+
continue
317+
}
318+
319+
const schema = response200.content?.['application/json']?.schema
320+
if (!schema) {
321+
errors.push(`${testCase.description}: No JSON schema defined for 200 response`)
322+
continue
323+
}
324+
325+
// Build the actual URL by replacing path parameters
326+
let actualPath = testCase.openApiPath
327+
const queryParams: Record<string, string> = {}
328+
329+
if (testCase.params) {
330+
for (const [key, value] of Object.entries(testCase.params)) {
331+
const placeholder = `{${key}}`
332+
if (actualPath.includes(placeholder)) {
333+
actualPath = actualPath.replace(placeholder, String(value))
334+
} else {
335+
queryParams[key] = String(value)
336+
}
337+
}
338+
}
339+
340+
// Add query string if needed
341+
const queryString =
342+
Object.keys(queryParams).length > 0
343+
? '?' + new URLSearchParams(queryParams).toString()
344+
: ''
345+
346+
// Make the actual API call
347+
const response = await $fetch<unknown>(`${actualPath}${queryString}`)
348+
349+
if (!response) {
350+
errors.push(`${testCase.description}: No response received from API`)
351+
continue
352+
}
353+
354+
// Verify response has expected top-level fields from OpenAPI
355+
if (typeof response === 'object' && response !== null) {
356+
const responseObj = response as Record<string, unknown>
357+
358+
// Check required fields
359+
if (!('success' in responseObj)) {
360+
errors.push(`${testCase.description}: Missing 'success' field in response`)
361+
}
362+
363+
if (!('data' in responseObj)) {
364+
errors.push(`${testCase.description}: Missing 'data' field in response`)
365+
}
366+
367+
if (!('meta' in responseObj)) {
368+
errors.push(`${testCase.description}: Missing 'meta' field in response`)
369+
}
370+
371+
// Validate meta structure
372+
if ('meta' in responseObj && typeof responseObj.meta === 'object') {
373+
const meta = responseObj.meta as Record<string, unknown>
374+
const requiredMetaFields = ['requestId', 'responseTime', 'timestamp', 'version']
375+
for (const field of requiredMetaFields) {
376+
if (!(field in meta)) {
377+
errors.push(`${testCase.description}: Missing 'meta.${field}' field in response`)
378+
}
379+
}
380+
}
381+
}
382+
383+
// Validate the response structure using Zod
384+
const apiResponseSchema = z.fromJSONSchema({
385+
...JSON.parse(JSON.stringify(schema).replaceAll('#/components/schemas/', '#/$defs/')),
386+
$defs: JSON.parse(
387+
JSON.stringify(openApiSpec.components.schemas).replaceAll(
388+
'#/components/schemas/',
389+
'#/$defs/'
390+
)
391+
)
392+
})
393+
394+
const validationResult = apiResponseSchema.safeParse(response)
395+
if (!validationResult.success) {
396+
errors.push(
397+
`${testCase.description}: Response doesn't match API envelope structure: ${validationResult.error.message}`
398+
)
399+
}
400+
} catch (error) {
401+
errors.push(
402+
`${testCase.description}: Failed to fetch or validate - ${error instanceof Error ? error.message : String(error)}`
403+
)
404+
}
405+
}
406+
407+
if (errors.length > 0) {
408+
const errorMessage = [
409+
'API response validation failed:',
410+
'',
411+
...errors.map((e) => ` - ${e}`),
412+
'',
413+
`Total test cases: ${testCases.length}`,
414+
`Total errors: ${errors.length}`
415+
].join('\n')
416+
417+
// eslint-disable-next-line vitest/no-conditional-expect
418+
expect.fail(errorMessage)
419+
}
420+
})
421+
254422
it('should not have type objects without properties defined', async () => {
255423
const openApiSpec = await $fetch<OpenApiSpec>('/_docs/openapi.json')
256424

0 commit comments

Comments
 (0)