11import { $fetch , setup } from '@nuxt/test-utils/e2e'
22import { fileURLToPath } from 'node:url'
33import { describe , expect , it } from 'vitest'
4+ import { z } from 'zod'
45
56await setup ( { rootDir : fileURLToPath ( new URL ( '../../' , import . meta. url ) ) } )
67
78type 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