@@ -10,6 +10,7 @@ import {
1010 QuotaUsageType ,
1111 RelationshipType ,
1212 StaticQuotaName ,
13+ type ImportProjectRequest ,
1314 type ImportProjectResponse ,
1415 type Row ,
1516 type RowAttachment ,
@@ -172,14 +173,96 @@ describe("Project data export and import", () => {
172173 } )
173174 } )
174175
175- it . each ( [ undefined , "example-password" ] ) (
176- "imports related rows and attachments twice independently (password: %s)" ,
177- async encryptPassword => {
176+ const importProjectData = async ( {
177+ archive,
178+ options,
179+ } : {
180+ archive : Buffer
181+ options : ImportProjectRequest
182+ } ) => {
183+ const response = await config . api . project . import ( archive , options )
184+ return { response, ...( await readImportedData ( response ) ) }
185+ }
186+
187+ const packageFormats = [
188+ { format : "unencrypted" , options : { } } ,
189+ { format : "encrypted" , options : { encryptPassword : "example-password" } } ,
190+ ]
191+
192+ const readAttachmentContent = async ( attachment : RowAttachment ) => {
193+ const { stream } = await objectStore . getReadStream (
194+ ObjectStoreBuckets . APPS ,
195+ attachment . key !
196+ )
197+ return await buffer ( stream )
198+ }
199+
200+ it . each ( packageFormats ) (
201+ "imports related rows and attachment content from an $format package" ,
202+ async ( { options } ) => {
178203 await withProjectsEnabled ( async ( ) => {
179204 const source = await createDataProject ( )
180205 const archive = await config . api . project . export ( source . project . _id , {
181206 includeRows : true ,
182- encryptPassword,
207+ ...options ,
208+ } )
209+ const destination = await config . api . workspace . create ( {
210+ name : "Destination" ,
211+ } )
212+ await config . withHeaders (
213+ { [ Header . WORKSPACE_ID ] : destination . appId } ,
214+ async ( ) => {
215+ const imported = await importProjectData ( { archive, options } )
216+ const openCategory = imported . categoryRows . find (
217+ row => row . name === "Open"
218+ ) !
219+ const attachment : RowAttachment = imported . rows [ 0 ] . attachment
220+
221+ expect ( imported . response . dataImport ) . toEqual ( {
222+ tables : 2 ,
223+ rows : 3 ,
224+ relationships : 1 ,
225+ attachments : 1 ,
226+ } )
227+ expect ( imported . rows ) . toEqual ( [
228+ expect . objectContaining ( {
229+ name : "First task" ,
230+ description : source . description ,
231+ autoId : 42 ,
232+ tableId : imported . tasks . _id ,
233+ category : [ expect . objectContaining ( { _id : openCategory . _id } ) ] ,
234+ attachment : expect . objectContaining ( {
235+ name : source . attachment . name ,
236+ size : attachmentContent . length ,
237+ } ) ,
238+ gallery : [ expect . objectContaining ( { key : attachment . key } ) ] ,
239+ } ) ,
240+ ] )
241+ expect ( imported . categoryRows . map ( row => row . name ) . sort ( ) ) . toEqual ( [
242+ "Closed" ,
243+ "Open" ,
244+ ] )
245+ expect ( openCategory . tasks ) . toEqual ( [
246+ expect . objectContaining ( { _id : imported . rows [ 0 ] . _id } ) ,
247+ ] )
248+ expect ( attachment . key ) . not . toBe ( source . attachment . key )
249+ expect ( await readAttachmentContent ( attachment ) ) . toEqual (
250+ attachmentContent
251+ )
252+ }
253+ )
254+ } )
255+ }
256+ )
257+
258+ it . each ( packageFormats ) (
259+ "re-imports an $format package independently, preserving existing data and counting new rows without triggering automations" ,
260+ async ( { options } ) => {
261+ await withProjectsEnabled ( async ( ) => {
262+ const source = await createDataProject ( )
263+ const archive = await config . api . project . export ( source . project . _id , {
264+ includeRows : true ,
265+ ...options ,
183266 } )
184267 const destination = await config . api . workspace . create ( {
185268 name : "Destination" ,
@@ -203,72 +286,75 @@ describe("Project data export and import", () => {
203286 )
204287 const emitRow = jest . spyOn ( BudibaseEmitter . prototype , "emitRow" )
205288 try {
206- const imports = [ ]
207- for ( let i = 0 ; i < 2 ; i ++ ) {
208- const imported = await config . api . project . import (
209- archive ,
210- encryptPassword ? { encryptPassword } : undefined
211- )
212- const data = await readImportedData ( imported )
213- const openCategory = data . categoryRows . find (
214- row => row . name === "Open"
215- ) !
216- const attachment : RowAttachment = data . rows [ 0 ] . attachment
217- const { stream } = await objectStore . getReadStream (
218- ObjectStoreBuckets . APPS ,
219- attachment . key !
220- )
221-
222- expect ( imported . dataImport ) . toEqual ( {
223- tables : 2 ,
224- rows : 3 ,
225- relationships : 1 ,
226- attachments : 1 ,
227- } )
228- expect ( data . rows ) . toEqual ( [
229- expect . objectContaining ( {
230- name : "First task" ,
231- description : source . description ,
232- autoId : 42 ,
233- tableId : data . tasks . _id ,
234- category : [
235- expect . objectContaining ( { _id : openCategory . _id } ) ,
236- ] ,
237- attachment : expect . objectContaining ( {
238- name : source . attachment . name ,
239- size : attachmentContent . length ,
240- } ) ,
241- gallery : [ expect . objectContaining ( { key : attachment . key } ) ] ,
242- } ) ,
243- ] )
244- expect ( data . categoryRows . map ( row => row . name ) . sort ( ) ) . toEqual ( [
245- "Closed" ,
246- "Open" ,
247- ] )
248- expect ( openCategory . tasks ) . toEqual ( [
249- expect . objectContaining ( { _id : data . rows [ 0 ] . _id } ) ,
250- ] )
251- expect ( attachment . key ) . not . toBe ( source . attachment . key )
252- expect ( await buffer ( stream ) ) . toEqual ( attachmentContent )
253- imports . push ( data )
254- }
289+ const firstImport = await importProjectData ( { archive, options } )
290+ const secondImport = await importProjectData ( { archive, options } )
291+ const firstOpenCategory = firstImport . categoryRows . find (
292+ row => row . name === "Open"
293+ ) !
294+ const secondOpenCategory = secondImport . categoryRows . find (
295+ row => row . name === "Open"
296+ ) !
297+ const firstAttachment : RowAttachment =
298+ firstImport . rows [ 0 ] . attachment
299+ const secondAttachment : RowAttachment =
300+ secondImport . rows [ 0 ] . attachment
301+ const usageAfter = await config . doInContext (
302+ destination . appId ,
303+ ( ) =>
304+ quotas . getCurrentUsageValues (
305+ QuotaUsageType . STATIC ,
306+ StaticQuotaName . ROWS
307+ )
308+ )
255309
256310 expect (
257311 new Set ( [
258312 source . tasks . _id ,
259- imports [ 0 ] . tasks . _id ,
260- imports [ 1 ] . tasks . _id ,
313+ firstImport . tasks . _id ,
314+ secondImport . tasks . _id ,
261315 ] ) . size
262316 ) . toBe ( 3 )
263317 expect (
264318 new Set ( [
265319 source . task . _id ,
266- imports [ 0 ] . rows [ 0 ] . _id ,
267- imports [ 1 ] . rows [ 0 ] . _id ,
320+ firstImport . rows [ 0 ] . _id ,
321+ secondImport . rows [ 0 ] . _id ,
322+ ] ) . size
323+ ) . toBe ( 3 )
324+ expect (
325+ new Set ( [
326+ source . categories . _id ,
327+ firstImport . categories . _id ,
328+ secondImport . categories . _id ,
268329 ] ) . size
269330 ) . toBe ( 3 )
270- expect ( imports [ 0 ] . rows [ 0 ] . attachment . key ) . not . toBe (
271- imports [ 1 ] . rows [ 0 ] . attachment . key
331+ expect (
332+ new Set ( [
333+ source . category . _id ,
334+ firstOpenCategory . _id ,
335+ secondOpenCategory . _id ,
336+ ] ) . size
337+ ) . toBe ( 3 )
338+ expect ( firstImport . rows [ 0 ] . category ) . toEqual ( [
339+ expect . objectContaining ( { _id : firstOpenCategory . _id } ) ,
340+ ] )
341+ expect ( secondImport . rows [ 0 ] . category ) . toEqual ( [
342+ expect . objectContaining ( { _id : secondOpenCategory . _id } ) ,
343+ ] )
344+ expect ( firstOpenCategory . tasks ) . toEqual ( [
345+ expect . objectContaining ( { _id : firstImport . rows [ 0 ] . _id } ) ,
346+ ] )
347+ expect ( secondOpenCategory . tasks ) . toEqual ( [
348+ expect . objectContaining ( { _id : secondImport . rows [ 0 ] . _id } ) ,
349+ ] )
350+ expect ( firstAttachment . key ) . not . toBe ( source . attachment . key )
351+ expect ( secondAttachment . key ) . not . toBe ( source . attachment . key )
352+ expect ( firstAttachment . key ) . not . toBe ( secondAttachment . key )
353+ expect ( await readAttachmentContent ( firstAttachment ) ) . toEqual (
354+ attachmentContent
355+ )
356+ expect ( await readAttachmentContent ( secondAttachment ) ) . toEqual (
357+ attachmentContent
272358 )
273359 expect ( await config . api . row . fetch ( existing . _id ! ) ) . toEqual ( [
274360 expect . objectContaining ( {
@@ -277,14 +363,6 @@ describe("Project data export and import", () => {
277363 } ) ,
278364 ] )
279365 expect ( emitRow ) . not . toHaveBeenCalled ( )
280- const usageAfter = await config . doInContext (
281- destination . appId ,
282- ( ) =>
283- quotas . getCurrentUsageValues (
284- QuotaUsageType . STATIC ,
285- StaticQuotaName . ROWS
286- )
287- )
288366 expect ( usageAfter . total - usageBefore . total ) . toBe ( 6 )
289367 expect ( usageAfter . app ! - usageBefore . app ! ) . toBe ( 6 )
290368 } finally {
@@ -357,6 +435,33 @@ describe("Project data export and import", () => {
357435 ) ,
358436 } ) )
359437
438+ const failRowImportAfterFirstWrite = ( {
439+ workspaceId,
440+ } : {
441+ workspaceId : string
442+ } ) => {
443+ const originalBulkDocs = DatabaseImpl . prototype . bulkDocs
444+ return jest
445+ . spyOn ( DatabaseImpl . prototype , "bulkDocs" )
446+ . mockImplementation ( async function ( this : DatabaseImpl , docs ) {
447+ if (
448+ context . getWorkspaceId ( ) === workspaceId &&
449+ docs . some ( doc => doc . _id ?. startsWith ( "ro_" ) && ! doc . _deleted )
450+ ) {
451+ const saved = await this . put ( docs [ 0 ] )
452+ return [
453+ { id : saved . id , rev : saved . rev } ,
454+ ...docs . slice ( 1 ) . map ( doc => ( {
455+ id : doc . _id ! ,
456+ error : "conflict" ,
457+ reason : "import failed" ,
458+ } ) ) ,
459+ ]
460+ }
461+ return await originalBulkDocs . call ( this , docs )
462+ } )
463+ }
464+
360465 it ( "rolls back partial row writes and uploaded attachments without changing existing data" , async ( ) => {
361466 await withProjectsEnabled ( async ( ) => {
362467 const source = await createDataProject ( )
@@ -365,26 +470,9 @@ describe("Project data export and import", () => {
365470 } )
366471 const destination = await createDestination ( )
367472 const before = await snapshotWorkspace ( destination . appId )
368- const originalBulkDocs = DatabaseImpl . prototype . bulkDocs
369- const bulkDocs = jest
370- . spyOn ( DatabaseImpl . prototype , "bulkDocs" )
371- . mockImplementation ( async function ( this : DatabaseImpl , docs ) {
372- if (
373- context . getWorkspaceId ( ) === destination . appId &&
374- docs . some ( doc => doc . _id ?. startsWith ( "ro_" ) && ! doc . _deleted )
375- ) {
376- const saved = await this . put ( docs [ 0 ] )
377- return [
378- { id : saved . id , rev : saved . rev } ,
379- ...docs . slice ( 1 ) . map ( doc => ( {
380- id : doc . _id ! ,
381- error : "conflict" ,
382- reason : "import failed" ,
383- } ) ) ,
384- ]
385- }
386- return await originalBulkDocs . call ( this , docs )
387- } )
473+ const bulkDocs = failRowImportAfterFirstWrite ( {
474+ workspaceId : destination . appId ,
475+ } )
388476
389477 try {
390478 await config . withHeaders (
0 commit comments