@@ -11,23 +11,28 @@ const {
1111 mockGetFileMetadataById,
1212 mockVerifyFileAccess,
1313 mockDownloadFile,
14- mockExtractEmbeddedImageIds ,
14+ mockExtractEmbeddedFileRefs ,
1515} = vi . hoisted ( ( ) => ( {
1616 mockCheckAuth : vi . fn ( ) ,
1717 mockGetFileMetadataById : vi . fn ( ) ,
1818 mockVerifyFileAccess : vi . fn ( ) ,
1919 mockDownloadFile : vi . fn ( ) ,
20- mockExtractEmbeddedImageIds : vi . fn ( ) ,
20+ mockExtractEmbeddedFileRefs : vi . fn ( ) ,
2121} ) )
2222
23+ /** `embedded-image-refs.test.ts` covers the grammar itself. */
24+ function embeds ( ...ids : string [ ] ) {
25+ mockExtractEmbeddedFileRefs . mockReturnValue ( { keys : [ ] , ids } )
26+ }
27+
2328vi . mock ( '@/lib/auth/hybrid' , ( ) => ( { checkSessionOrInternalAuth : mockCheckAuth } ) )
2429vi . mock ( '@/lib/uploads/server/metadata' , ( ) => ( {
2530 getFileMetadataById : mockGetFileMetadataById ,
2631} ) )
2732vi . mock ( '@/app/api/files/authorization' , ( ) => ( { verifyFileAccess : mockVerifyFileAccess } ) )
2833vi . mock ( '@/lib/uploads/core/storage-service' , ( ) => ( { downloadFile : mockDownloadFile } ) )
29- vi . mock ( '@/lib/copilot/tools/ server/files /embedded-image-refs' , ( ) => ( {
30- extractEmbeddedImageIds : mockExtractEmbeddedImageIds ,
34+ vi . mock ( '@/lib/uploads/ server/embedded-image-refs' , ( ) => ( {
35+ extractEmbeddedFileRefs : mockExtractEmbeddedFileRefs ,
3136} ) )
3237vi . mock ( '@sim/audit' , ( ) => ( {
3338 recordAudit : vi . fn ( ) ,
@@ -58,43 +63,35 @@ function assetRecord(id: string, size: number) {
5863 }
5964}
6065
61- describe ( 'markdown export bundling' , ( ) => {
62- beforeEach ( ( ) => {
63- vi . clearAllMocks ( )
64- mockCheckAuth . mockResolvedValue ( { success : true , userId : 'user-1' } )
65- mockVerifyFileAccess . mockResolvedValue ( true )
66- mockGetFileMetadataById . mockImplementation ( async ( id : string ) =>
67- id === DOC_ID
68- ? {
69- id : DOC_ID ,
70- key : 'workspace/ws-1/doc.md' ,
71- originalName : 'doc.md' ,
72- contentType : 'text/markdown' ,
73- context : 'workspace' ,
74- size : 1024 ,
75- workspaceId : 'ws-1' ,
76- }
77- : assetRecord ( id , 1 * MB )
78- )
79- mockDownloadFile . mockResolvedValue ( Buffer . from ( '# Doc\n' ) )
80- mockExtractEmbeddedImageIds . mockReturnValue ( [ ] )
81- } )
66+ const DOC_RECORD = {
67+ id : DOC_ID ,
68+ key : 'workspace/ws-1/doc.md' ,
69+ originalName : 'doc.md' ,
70+ contentType : 'text/markdown' ,
71+ context : 'workspace' ,
72+ size : 1024 ,
73+ workspaceId : 'ws-1' ,
74+ }
75+
76+ function assetsResolveTo ( assetFor : ( id : string ) => unknown ) {
77+ mockGetFileMetadataById . mockImplementation ( async ( id : string ) =>
78+ id === DOC_ID ? DOC_RECORD : assetFor ( id )
79+ )
80+ }
8281
82+ beforeEach ( ( ) => {
83+ vi . clearAllMocks ( )
84+ mockCheckAuth . mockResolvedValue ( { success : true , userId : 'user-1' } )
85+ mockVerifyFileAccess . mockResolvedValue ( true )
86+ assetsResolveTo ( ( id ) => assetRecord ( id , 1 * MB ) )
87+ mockDownloadFile . mockResolvedValue ( Buffer . from ( '# Doc\n' ) )
88+ embeds ( )
89+ } )
90+
91+ describe ( 'markdown export bundling' , ( ) => {
8392 it ( 'rejects on declared asset bytes before downloading any of them' , async ( ) => {
84- mockExtractEmbeddedImageIds . mockReturnValue ( [ 'a' , 'b' , 'c' ] )
85- mockGetFileMetadataById . mockImplementation ( async ( id : string ) =>
86- id === DOC_ID
87- ? {
88- id : DOC_ID ,
89- key : 'workspace/ws-1/doc.md' ,
90- originalName : 'doc.md' ,
91- contentType : 'text/markdown' ,
92- context : 'workspace' ,
93- size : 1024 ,
94- workspaceId : 'ws-1' ,
95- }
96- : assetRecord ( id , 100 * MB )
97- )
93+ embeds ( 'a' , 'b' , 'c' )
94+ assetsResolveTo ( ( id ) => assetRecord ( id , 100 * MB ) )
9895
9996 const response = await GET ( request ( ) , context )
10097
@@ -106,7 +103,7 @@ describe('markdown export bundling', () => {
106103
107104 it ( 'counts the document body against the export limit, not just its assets' , async ( ) => {
108105 // Assets alone sit under the cap; the body is what carries the bundle over it.
109- mockExtractEmbeddedImageIds . mockReturnValue ( [ 'a' ] )
106+ embeds ( 'a' )
110107 mockDownloadFile . mockResolvedValue ( Buffer . alloc ( 250 * MB ) )
111108
112109 const response = await GET ( request ( ) , context )
@@ -116,7 +113,7 @@ describe('markdown export bundling', () => {
116113 } )
117114
118115 it ( 'caps the document body read rather than loading it unbounded' , async ( ) => {
119- mockExtractEmbeddedImageIds . mockReturnValue ( [ ] )
116+ embeds ( )
120117
121118 await GET ( request ( ) , context )
122119
@@ -125,7 +122,7 @@ describe('markdown export bundling', () => {
125122 } )
126123
127124 it ( 'reports an oversized body as a size rejection, not a server error' , async ( ) => {
128- mockExtractEmbeddedImageIds . mockReturnValue ( [ ] )
125+ embeds ( )
129126 mockDownloadFile . mockRejectedValue (
130127 new PayloadSizeLimitError ( { label : 'storage file download' , maxBytes : 1 } )
131128 )
@@ -138,7 +135,7 @@ describe('markdown export bundling', () => {
138135 } )
139136
140137 it ( 'caps each asset download rather than trusting its declared size' , async ( ) => {
141- mockExtractEmbeddedImageIds . mockReturnValue ( [ 'a' ] )
138+ embeds ( 'a' )
142139
143140 await GET ( request ( ) , context )
144141
@@ -149,7 +146,7 @@ describe('markdown export bundling', () => {
149146 } )
150147
151148 it ( 'drops an unreadable asset instead of failing the whole export' , async ( ) => {
152- mockExtractEmbeddedImageIds . mockReturnValue ( [ 'good' , 'bad' ] )
149+ embeds ( 'good' , 'bad' )
153150 mockDownloadFile . mockImplementation ( async ( { key } : { key : string } ) => {
154151 if ( key . endsWith ( 'doc.md' ) ) return Buffer . from ( '# Doc\n\n' )
155152 if ( key . endsWith ( 'bad' ) ) throw new Error ( 'storage down' )
@@ -165,7 +162,7 @@ describe('markdown export bundling', () => {
165162 } )
166163
167164 it ( 'skips an asset the caller cannot read' , async ( ) => {
168- mockExtractEmbeddedImageIds . mockReturnValue ( [ 'secret' ] )
165+ embeds ( 'secret' )
169166 mockVerifyFileAccess . mockImplementation ( async ( key : string ) => ! key . endsWith ( 'secret' ) )
170167
171168 const response = await GET ( request ( ) , context )
@@ -177,3 +174,47 @@ describe('markdown export bundling', () => {
177174 )
178175 } )
179176} )
177+
178+ describe ( 'markdown export format' , ( ) => {
179+ async function expectPlainMarkdown ( response : Response ) {
180+ expect ( response . status ) . toBe ( 200 )
181+ expect ( response . headers . get ( 'Content-Type' ) ) . toBe ( 'text/markdown; charset=utf-8' )
182+ expect ( response . headers . get ( 'Content-Disposition' ) ) . toContain ( 'doc.md' )
183+ expect ( await response . text ( ) ) . toBe ( '# Doc\n' )
184+ }
185+
186+ it ( 'returns the document itself when it embeds nothing' , async ( ) => {
187+ await expectPlainMarkdown ( await GET ( request ( ) , context ) )
188+ } )
189+
190+ /**
191+ * The reported bug: a document that references files which no longer resolve downloaded as a zip
192+ * whose `assets/` folder was empty. The format follows what was bundled, not what was referenced.
193+ */
194+ it ( 'returns the document itself when no embed resolves to a file' , async ( ) => {
195+ embeds ( 'gone' , 'also-gone' )
196+ assetsResolveTo ( ( ) => null )
197+
198+ await expectPlainMarkdown ( await GET ( request ( ) , context ) )
199+ } )
200+
201+ it ( 'returns the document itself when every embed fails to download' , async ( ) => {
202+ embeds ( 'a' )
203+ mockDownloadFile . mockImplementation ( async ( { key } : { key : string } ) => {
204+ if ( key . endsWith ( 'doc.md' ) ) return Buffer . from ( '# Doc\n' )
205+ throw new Error ( 'storage down' )
206+ } )
207+
208+ await expectPlainMarkdown ( await GET ( request ( ) , context ) )
209+ } )
210+
211+ it ( 'bundles a zip once at least one embed resolves' , async ( ) => {
212+ embeds ( 'a' )
213+
214+ const response = await GET ( request ( ) , context )
215+
216+ expect ( response . headers . get ( 'Content-Type' ) ) . toBe ( 'application/zip' )
217+ const zip = await JSZip . loadAsync ( Buffer . from ( await response . arrayBuffer ( ) ) )
218+ expect ( zip . file ( 'assets/a.png' ) ) . not . toBeNull ( )
219+ } )
220+ } )
0 commit comments