1+ import { Buffer } from 'node:buffer' ;
12import { registerEndpoint } from '@nuxt/test-utils/runtime' ;
23import { describe , expect , it , vi } from 'vitest' ;
34
@@ -19,6 +20,39 @@ registerEndpoint('/api/v1/test-post.json', (event) => {
1920 return { success : true , data : contentType ?? null } ;
2021} ) ;
2122
23+ registerEndpoint ( '/api/v1/test-download.json' , ( event ) => {
24+ const url = event . node . req . url != null ? new URL ( event . node . req . url , 'http://localhost' ) : null ;
25+ const filename = url ?. searchParams . get ( 'f' ) ?. trim ( ) ;
26+ const useStar = url ?. searchParams . get ( 'star' ) === '1' ;
27+ const fallback = url ?. searchParams . get ( 'fb' ) ?? filename ?? '' ;
28+
29+ if ( ! filename ) {
30+ return {
31+ success : false ,
32+ errors : [ { message : 'filename required' } ] ,
33+ } ;
34+ }
35+
36+ const disposition : string [ ] = [ 'attachment' ] ;
37+ if ( useStar ) {
38+ disposition . push ( `filename="${ fallback || 'download.bin' } "` ) ;
39+ disposition . push ( `filename*=UTF-8''${ encodeURIComponent ( filename ) } ` ) ;
40+ }
41+ else {
42+ disposition . push ( `filename="${ filename } "` ) ;
43+ }
44+
45+ event . node . res . setHeader ( 'Content-Disposition' , disposition . join ( '; ' ) ) ;
46+ event . node . res . setHeader ( 'Content-Type' , 'application/octet-stream' ) ;
47+
48+ return Buffer . from ( `download:${ filename } ` , 'utf-8' ) ;
49+ } ) ;
50+
51+ registerEndpoint ( '/api/v1/test-put.json' , ( event ) => {
52+ const contentType = event . node . req . headers [ 'content-type' ] ;
53+ return { success : true , data : contentType ?? null } ;
54+ } ) ;
55+
2256registerEndpoint ( '/api/v1/test-403.json' , ( event ) => {
2357 event . node . res . statusCode = 403 ;
2458 return { error : 'Forbidden' } ;
@@ -49,6 +83,12 @@ registerEndpoint('/api/v1/test-false.json', () => {
4983} ) ;
5084
5185describe ( 'useApiRouteFetcher with real $fetch requests' , ( ) => {
86+ it ( 'should include download hook by default' , ( ) => {
87+ const { opt } = useApiRouteFetcher ( ) ;
88+ const hooks = ( opt ( ) . onResponse as SafeAny [ ] | undefined ) ?. map ( ( fn : SafeAny ) => fn . id ?? 'unknown' ) ;
89+ expect ( hooks ) . toContain ( 'responseDownload' ) ;
90+ } ) ;
91+
5292 it ( 'should send GET request with correct Content-Type (no body)' , async ( ) => {
5393 const { get, raw } = useApiRouteFetcher ( ) ;
5494 const rs1 = await get ( '/test-get.json' ) ;
@@ -70,6 +110,60 @@ describe('useApiRouteFetcher with real $fetch requests', () => {
70110 expect ( rs2 . _data ) . toEqual ( { success : true , data : 'application/json' } ) ;
71111 } ) ;
72112
113+ it ( 'should send PUT request with JSON Content-Type via req' , async ( ) => {
114+ const { req } = useApiRouteFetcher ( ) ;
115+ const rs = await req < unknown , SafeAny > ( '/test-put.json' , { method : 'put' , body : { key : 'value' } } ) ;
116+ logger . debug ( 'put JSON' , JSON . stringify ( rs ) ) ;
117+ expect ( rs . data ) . toBe ( 'application/json' ) ;
118+ } ) ;
119+
120+ it ( 'should download file when query filename provided' , async ( ) => {
121+ const { raw, req } = useApiRouteFetcher ( ) ;
122+ const rs = await raw < unknown , SafeAny > ( '/test-download.json' , {
123+ method : 'get' ,
124+ query : { f : 'report.txt' } ,
125+ responseType : 'blob' ,
126+ } ) ;
127+ const headers = Object . fromEntries ( rs . headers . entries ( ) ) ;
128+ const file = rs . _data as FileResult ;
129+ expect ( headers [ 'content-disposition' ] ) . toBe ( 'attachment; filename="report.txt"' ) ;
130+ expect ( headers [ 'content-type' ] ) . toBe ( 'application/octet-stream' ) ;
131+ expect ( file . name ) . toBe ( 'report.txt' ) ;
132+ expect ( Object . prototype . toString . call ( file . blob ) ) . toBe ( '[object Blob]' ) ;
133+ await expect ( file . blob . text ( ) ) . resolves . toBe ( 'download:report.txt' ) ;
134+
135+ const direct = await req < unknown , SafeAny > ( '/test-download.json' , {
136+ method : 'get' ,
137+ query : { f : 'report.txt' } ,
138+ responseType : 'blob' ,
139+ } ) ;
140+ expect ( ( direct as FileResult ) . name ) . toBe ( 'report.txt' ) ;
141+ } ) ;
142+
143+ it ( 'should prefer filename* when provided' , async ( ) => {
144+ const { raw } = useApiRouteFetcher ( ) ;
145+ const rs = await raw < unknown , SafeAny > ( '/test-download.json' , {
146+ method : 'get' ,
147+ query : { f : '报告.txt' , star : '1' , fb : 'fallback.txt' } ,
148+ responseType : 'blob' ,
149+ } ) ;
150+ const file = rs . _data as FileResult ;
151+ expect ( file . name ) . toBe ( '报告.txt' ) ;
152+ expect ( Object . prototype . toString . call ( file . blob ) ) . toBe ( '[object Blob]' ) ;
153+ await expect ( file . blob . text ( ) ) . resolves . toBe ( 'download:报告.txt' ) ;
154+ } ) ;
155+
156+ it ( 'should fail download when filename query missing' , async ( ) => {
157+ const { req } = useApiRouteFetcher ( ) ;
158+ await expect ( req ( '/test-download.json' , {
159+ responseType : 'blob' ,
160+ } ) ) . rejects . toSatisfy ( ( error : SafeAny ) => {
161+ return error instanceof ApiResultError
162+ && error . errorResult != null
163+ && error . errorResult . errors ?. [ 0 ] ?. message === 'filename required' ;
164+ } ) ;
165+ } ) ;
166+
73167 it ( 'should send POST request with FormData Content-Type' , async ( ) => {
74168 const { post } = useApiRouteFetcher ( ) ;
75169 const formData = new FormData ( ) ;
0 commit comments