Skip to content

Commit f695ff0

Browse files
doublefacedoubleface
doubleface
authored andcommittedMar 19, 2024·
feat: Add contract option to saveFiles
This option can be used in saveFiles options or in saveFiles entries. saveFiles entries take priority over saveFiles options.
1 parent 903162c commit f695ff0

File tree

5 files changed

+478
-48
lines changed

5 files changed

+478
-48
lines changed
 

‎packages/cozy-clisk/src/launcher/saveFiles.js

+116-18
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { dataUriToArrayBuffer } from '../libs/utils'
1515
* @property {boolean} [_cozy_file_to_create] - Internal use to count the number of files to download
1616
* @property {object} [fileAttributes] - metadata attributes to add to the resulting file object
1717
* @property {string} [subPath] - subPath of the destination folder path where to put the downloaded file
18+
* @property {object} [contract] - contract object associated to the file
19+
* @property {string} [contract.id] - id of the contract
20+
* @property {string} [contract.name] - name of the contract
1821
* @property {import('cozy-client/types/types').IOCozyFile} [existingFile] - already existing file corresponding to the entry
1922
* @property {boolean} [shouldReplace] - Internal result of the shouldReplaceFile function on the entry
2023
* @property {boolean} [forceReplaceFile] - should the konnector force the replace of the current file
@@ -28,6 +31,9 @@ import { dataUriToArrayBuffer } from '../libs/utils'
2831
* @property {Array<string>} fileIdAttributes - List of entry attributes considered as unique deduplication key
2932
* @property {Function} log - Logging function coming from the Launcher
3033
* @property {string} [subPath] - subPath of the destination folder path where to put the downloaded file
34+
* @property {object} [contract] - contract object associated to the files
35+
* @property {string} [contract.id] - id of the contract
36+
* @property {string} [contract.name] - name of the contract
3137
* @property {string} [contentType] - will force the contentType of the file if any
3238
* @property {Function} [downloadAndFormatFile] - this callback will download the file and format to be useable by cozy-client
3339
* @property {string} [qualificationLabel] - qualification label defined in cozy-client which will be used on all given files
@@ -41,6 +47,9 @@ import { dataUriToArrayBuffer } from '../libs/utils'
4147
* @property {Array<string>} fileIdAttributes - List of entry attributes considered as unique deduplication key
4248
* @property {Function} log - Logging function coming from the Launcher
4349
* @property {string} [subPath] - subPath of the destination folder path where to put the downloaded file
50+
* @property {object} [contract] - contract object associated to the file
51+
* @property {string} [contract.id] - id of the contract
52+
* @property {string} [contract.name] - name of the contract
4453
* @property {string} [contentType] - will force the contentType of the file if any
4554
* @property {Function} [downloadAndFormatFile] - this callback will download the file and format to be useable by cozy-client
4655
* @property {string} [qualificationLabel] - qualification label defined in cozy-client which will be used on all given files
@@ -107,6 +116,7 @@ const saveFiles = async (client, entries, folderPath, options) => {
107116
log,
108117
retry: options.retry,
109118
subPath: options.subPath,
119+
contract: options.contract,
110120
fileIdAttributes: options.fileIdAttributes,
111121
manifest: options.manifest,
112122
contentType: options.contentType,
@@ -184,7 +194,7 @@ const saveFiles = async (client, entries, folderPath, options) => {
184194

185195
/**
186196
* Ensure the existence of all destination folders : folderPath, options.subPath and all subPaths
187-
* which may be present in each entries.
197+
* which may be present in each entries + contracts.
188198
* If the user changes some folders during the execution of saveFile, we will catch and fix the
189199
* errors.
190200
*
@@ -201,28 +211,112 @@ async function ensureAllDestinationFolders({
201211
folderPath,
202212
options
203213
}) {
204-
const fileCollection = client.collection('io.cozy.files')
214+
try {
215+
const fileCollection = client.collection('io.cozy.files')
205216

206-
const pathsList = []
217+
const pathsList = []
207218

208-
// construct an Array with all the paths to ensure the existence of
209-
if (options.subPath) {
210-
pathsList.push(folderPath + '/' + options.subPath)
211-
}
212-
const notFilteredEntriesPathList = entries.map(entry =>
213-
entry.subPath ? folderPath + '/' + entry.subPath : false
214-
)
215-
const entriesPathList = Array.from(
216-
new Set(notFilteredEntriesPathList)
217-
).filter(Boolean)
218-
// @ts-ignore Argument of type 'string | false' is not assignable to parameter of type 'string'. Type 'boolean' is not assignable to type 'string'.ts(2345)
219-
pathsList.push(...entriesPathList)
219+
// construct an Array with all the paths to ensure the existence of
220+
if (options.subPath) {
221+
pathsList.push(folderPath + '/' + options.subPath)
222+
}
223+
const notFilteredEntriesPathList = entries.map(entry =>
224+
entry.subPath ? folderPath + '/' + entry.subPath : false
225+
)
226+
const entriesPathList = Array.from(
227+
new Set(notFilteredEntriesPathList)
228+
).filter(Boolean)
229+
// @ts-ignore Argument of type 'string | false' is not assignable to parameter of type 'string'. Type 'boolean' is not assignable to type 'string'.ts(2345)
230+
pathsList.push(...entriesPathList)
231+
232+
for (const path of pathsList) {
233+
await fileCollection.ensureDirectoryExists(path)
234+
}
235+
236+
const slug = options?.manifest?.slug
237+
const { included: existingKonnectorFolders } = await client.query(
238+
Q('io.cozy.files')
239+
.where({})
240+
.partialIndex({ type: 'directory', trashed: false })
241+
.referencedBy({
242+
_type: 'io.cozy.konnectors',
243+
_id: `io.cozy.konnectors/${slug}`
244+
})
245+
)
246+
247+
const sourceAccountIdentifier =
248+
options.sourceAccountOptions?.sourceAccountIdentifier
249+
const existingAccountFolders = existingKonnectorFolders.filter(folder =>
250+
Boolean(
251+
folder.referenced_by?.find(
252+
ref =>
253+
ref.type === 'io.cozy.accounts.sourceAccountIdentifier' &&
254+
(sourceAccountIdentifier
255+
? ref.id === sourceAccountIdentifier
256+
: true)
257+
)
258+
)
259+
)
220260

221-
for (const path of pathsList) {
222-
await fileCollection.ensureDirectoryExists(path)
261+
const contractsList = Array.from(
262+
new Set(entries.map(entry => entry.contract))
263+
).filter(Boolean)
264+
if (options.contract) {
265+
contractsList.unshift(options.contract)
266+
}
267+
268+
for (const contract of contractsList) {
269+
if (
270+
!contract ||
271+
existingAccountFolders.find(folder =>
272+
hasContractReference(folder, contract.id)
273+
)
274+
) {
275+
continue
276+
}
277+
278+
const { data: folder } = await fileCollection.createDirectoryByPath(
279+
folderPath + '/' + contract.name
280+
)
281+
await Promise.all([
282+
fileCollection.addReferencesTo(
283+
{
284+
_id: `io.cozy.konnectors/${slug}`,
285+
_type: 'io.cozy.konnector'
286+
},
287+
[folder]
288+
),
289+
fileCollection.addReferencesTo(
290+
{
291+
_id: sourceAccountIdentifier,
292+
_type: 'io.cozy.accounts.sourceAccountIdentifier'
293+
},
294+
[folder]
295+
),
296+
fileCollection.addReferencesTo(
297+
{
298+
_id: contract.id,
299+
_type: 'io.cozy.accounts.contracts'
300+
},
301+
[folder]
302+
)
303+
])
304+
}
305+
} catch (err) {
306+
throw new Error(
307+
`cozy-clisk::saveFiles Error in ensureAllDestinationFolders. Cannot save files: ${err.message}`
308+
)
223309
}
224310
}
225311

312+
function hasContractReference(folder, contractId) {
313+
return Boolean(
314+
folder.referenced_by?.find(
315+
ref => ref.type === 'io.cozy.accounts.contracts' && ref.id === contractId
316+
)
317+
)
318+
}
319+
226320
/**
227321
* Saves a single file entry
228322
*
@@ -380,7 +474,11 @@ async function createFileWithFolderOnError(
380474
method,
381475
file
382476
) {
383-
const subPath = entry.subPath || options.subPath
477+
const subPath =
478+
entry.contract?.name ||
479+
options.contract?.name ||
480+
entry.subPath ||
481+
options.subPath
384482
const finalPath = subPath
385483
? options.folderPath + '/' + subPath
386484
: options.folderPath

‎packages/cozy-clisk/src/launcher/saveFiles.spec.js

+66
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ describe('saveFiles', function () {
2222
const existingFilesIndex = new Map([['old file name.txt', fileDocument]])
2323
const client = {
2424
save: jest.fn(),
25+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
2526
collection: () => ({
2627
statByPath: jest.fn().mockImplementation(path => {
2728
return { data: { _id: path } }
@@ -78,6 +79,7 @@ describe('saveFiles', function () {
7879
save: jest.fn().mockImplementation(doc => ({
7980
data: { ...doc, _rev: 'newrev' }
8081
})),
82+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
8183
collection: () => ({
8284
statByPath: jest.fn().mockImplementation(path => {
8385
return { data: { _id: path } }
@@ -142,6 +144,7 @@ describe('saveFiles', function () {
142144
save: jest.fn().mockImplementation(doc => ({
143145
data: doc
144146
})),
147+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
145148
collection: () => ({
146149
statByPath: jest
147150
.fn()
@@ -198,6 +201,7 @@ describe('saveFiles', function () {
198201
save: jest.fn().mockImplementation(doc => ({
199202
data: doc
200203
})),
204+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
201205
collection: () => ({
202206
statByPath: jest.fn().mockImplementation(path => {
203207
return { data: { _id: path } }
@@ -253,6 +257,7 @@ describe('saveFiles', function () {
253257
save: jest.fn().mockImplementation(doc => ({
254258
data: doc
255259
})),
260+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
256261
collection: () => ({
257262
statByPath: jest.fn().mockImplementation(path => {
258263
return { data: { _id: path } }
@@ -298,6 +303,7 @@ describe('saveFiles', function () {
298303
save: jest.fn().mockImplementation(doc => ({
299304
data: doc
300305
})),
306+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
301307
collection: () => ({
302308
statByPath: jest.fn().mockImplementation(path => {
303309
return { data: { _id: path } }
@@ -349,6 +355,7 @@ describe('saveFiles', function () {
349355
save: jest.fn().mockImplementation(doc => ({
350356
data: doc
351357
})),
358+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
352359
collection: () => ({
353360
statByPath: jest.fn().mockImplementation(path => {
354361
return { data: { _id: path } }
@@ -396,6 +403,7 @@ describe('saveFiles', function () {
396403
}
397404
return { data: doc }
398405
}),
406+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
399407
collection: () => ({
400408
statByPath: jest.fn().mockImplementation(path => {
401409
return { data: { _id: path } }
@@ -449,6 +457,7 @@ describe('saveFiles', function () {
449457

450458
const client = {
451459
save: jest.fn().mockRejectedValueOnce(notExistingDirectoryErr),
460+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
452461
collection: () => ({
453462
statByPath: jest.fn().mockImplementation(path => {
454463
return { data: { _id: path } }
@@ -480,6 +489,7 @@ describe('saveFiles', function () {
480489
save: jest.fn().mockImplementation(doc => ({
481490
data: doc
482491
})),
492+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
483493
collection: () => ({
484494
statByPath: jest
485495
.fn()
@@ -510,4 +520,60 @@ describe('saveFiles', function () {
510520
}
511521
expect(caught).toBe(false)
512522
})
523+
it('should save a file with a contract', async () => {
524+
const ensureDirectoryExists = jest
525+
.fn()
526+
.mockImplementation(async path => path)
527+
const createDirectoryByPath = jest
528+
.fn()
529+
.mockImplementation(async path => ({ data: { path } }))
530+
const client = {
531+
save: jest.fn().mockImplementation(doc => ({
532+
data: doc
533+
})),
534+
query: jest.fn().mockResolvedValue({ included: [], data: null }),
535+
collection: () => ({
536+
statByPath: jest.fn().mockImplementation(path => {
537+
return { data: { _id: path } }
538+
}),
539+
ensureDirectoryExists,
540+
createDirectoryByPath,
541+
addReferencesTo: jest.fn()
542+
})
543+
}
544+
const document = {
545+
filestream: 'filestream content',
546+
filename: 'file name.txt'
547+
}
548+
const result = await saveFiles(client, [document], '/test/folder/path', {
549+
manifest: {
550+
slug: 'testslug'
551+
},
552+
contract: {
553+
id: 'testContractId',
554+
name: 'testContractName'
555+
},
556+
sourceAccount: 'testsourceaccount',
557+
sourceAccountIdentifier: 'testsourceaccountidentifier',
558+
fileIdAttributes: ['filename'],
559+
existingFilesIndex: new Map(),
560+
log: jest.fn()
561+
})
562+
expect(createDirectoryByPath).toHaveBeenCalledWith(
563+
'/test/folder/path/testContractName'
564+
)
565+
expect(client.save).toHaveBeenCalledWith(
566+
expect.objectContaining({
567+
dirId: '/test/folder/path/testContractName'
568+
})
569+
)
570+
expect(result).toStrictEqual([
571+
{
572+
filename: 'file name.txt',
573+
fileDocument: expect.objectContaining({
574+
dirId: '/test/folder/path/testContractName'
575+
})
576+
}
577+
])
578+
})
513579
})

‎packages/cozy-konnector-libs/docs/api.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ Saves the given files in the given folder via the Cozy API.
494494

495495
<a name="exp_module_saveFiles--saveFiles"></a>
496496

497-
### saveFiles(entries, fields, options) ⏏
497+
### saveFiles(entries, fields, options, [contract]) ⏏
498498
Saves the files given in the fileurl attribute of each entries
499499

500500
You need the full permission on `io.cozy.files` in your manifest to use this function.
@@ -513,6 +513,9 @@ You need the full permission on `io.cozy.files` in your manifest to use this fun
513513
| entries.shouldReplaceFile | <code>function</code> | use this function to state if the current entry should be forced to be redownloaded and replaced. Usefull if we know the file content can change and we always want the last version. |
514514
| entries.fileAttributes | <code>object</code> | ex: `{created_at: new Date()}` sets some additionnal file attributes passed to cozyClient.file.create |
515515
| entries.subPath | <code>string</code> | A subpath to save all files, will be created if needed. |
516+
| entries.contract | <code>object</code> | contract object associated to the files |
517+
| entries.contract.id | <code>string</code> | id of the contract |
518+
| entries.contract.name | <code>string</code> | name of the contract |
516519
| fields | <code>object</code> | is the argument given to the main function of your connector by the BaseKonnector. It especially contains a `folderPath` which is the string path configured by the user in collect/home |
517520
| options | <code>object</code> | global options |
518521
| options.timeout | <code>number</code> | timestamp which can be used if your connector needs to fetch a lot of files and if the stack does not give enough time to your connector to fetch it all. It could happen that the connector is stopped right in the middle of the download of the file and the file will be broken. With the `timeout` option, the `saveFiles` function will check if the timeout has passed right after downloading each file and then will be sure to be stopped cleanly if the timeout is not too long. And since it is really fast to check that a file has already been downloaded, on the next run of the connector, it will be able to download some more files, and so on. If you want the timeout to be in 10s, do `Date.now() + 10*1000`. You can try it in the previous code. |
@@ -522,6 +525,9 @@ You need the full permission on `io.cozy.files` in your manifest to use this fun
522525
| options.validateFileContent | <code>boolean</code> \| <code>function</code> | default false. Also check the content of the file to recognize the mime type |
523526
| options.fileIdAttributes | <code>Array</code> | array of strings : Describes which attributes of files will be taken as primary key for files to check if they already exist, even if they are moved. If not given, the file path will used for deduplication as before. |
524527
| options.subPath | <code>string</code> | A subpath to save this file, will be created if needed. |
528+
| [contract] | <code>object</code> | contract object associated to the file |
529+
| [contract.id] | <code>string</code> | id of the contract |
530+
| [contract.name] | <code>string</code> | name of the contract |
525531
| options.fetchFile | <code>function</code> | the connector can give it's own function to fetch the file from the website, which will be run only when necessary (if the corresponding file is missing on the cozy) function returning the stream). This function must return a promise resolved as a stream |
526532
| options.verboseFilesLog | <code>boolean</code> | the connector will send saveFiles result as a warning |
527533

‎packages/cozy-konnector-libs/src/libs/saveFiles.js

+117-15
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ const FILES_DOCTYPE = 'io.cozy.files'
5050
* @param {Function} entries.shouldReplaceFile - use this function to state if the current entry should be forced to be redownloaded and replaced. Usefull if we know the file content can change and we always want the last version.
5151
* @param {object} entries.fileAttributes - ex: `{created_at: new Date()}` sets some additionnal file attributes passed to cozyClient.file.create
5252
* @param {string} entries.subPath - A subpath to save all files, will be created if needed.
53+
* @param {object} entries.contract - contract object associated to the files
54+
* @param {string} entries.contract.id - id of the contract
55+
* @param {string} entries.contract.name - name of the contract
5356
* @param {object} fields - is the argument given to the main function of your connector by the BaseKonnector. It especially contains a `folderPath` which is the string path configured by the user in collect/home
5457
* @param {object} options - global options
5558
* @param {number} options.timeout - timestamp which can be used if your connector needs to fetch a lot of files and if the stack does not give enough time to your connector to fetch it all. It could happen that the connector is stopped right in the middle of the download of the file and the file will be broken. With the `timeout` option, the `saveFiles` function will check if the timeout has passed right after downloading each file and then will be sure to be stopped cleanly if the timeout is not too long. And since it is really fast to check that a file has already been downloaded, on the next run of the connector, it will be able to download some more files, and so on. If you want the timeout to be in 10s, do `Date.now() + 10*1000`. You can try it in the previous code.
@@ -59,6 +62,10 @@ const FILES_DOCTYPE = 'io.cozy.files'
5962
* @param {boolean|Function} options.validateFileContent - default false. Also check the content of the file to recognize the mime type
6063
* @param {Array} options.fileIdAttributes - array of strings : Describes which attributes of files will be taken as primary key for files to check if they already exist, even if they are moved. If not given, the file path will used for deduplication as before.
6164
* @param {string} options.subPath - A subpath to save this file, will be created if needed.
65+
* @param {object} [contract] - contract object associated to the file
66+
* @param {string} [contract.id] - id of the contract
67+
* @param {string} [contract.name] - name of the contract
68+
6269
* @param {Function} options.fetchFile - the connector can give it's own function to fetch the file from the website, which will be run only when necessary (if the corresponding file is missing on the cozy) function returning the stream). This function must return a promise resolved as a stream
6370
* @param {boolean} options.verboseFilesLog - the connector will send saveFiles result as a warning
6471
* @example
@@ -102,6 +109,7 @@ const saveFiles = async (entries, fields, options = {}) => {
102109
shouldReplaceFile: options.shouldReplaceFile,
103110
validateFile: options.validateFile || defaultValidateFile,
104111
subPath: options.subPath,
112+
contract: options.contract,
105113
sourceAccountOptions: {
106114
sourceAccount: options.sourceAccount,
107115
sourceAccountIdentifier: options.sourceAccountIdentifier
@@ -121,6 +129,29 @@ const saveFiles = async (entries, fields, options = {}) => {
121129
const canBeSaved = entry =>
122130
entry.fetchFile || entry.fileurl || entry.requestOptions || entry.filestream
123131

132+
// get all konnector folders
133+
const slug = manifest.data.slug
134+
const { included: existingKonnectorFolders } = await client.query(
135+
Q('io.cozy.files')
136+
.where({})
137+
.partialIndex({ type: 'directory', trashed: false })
138+
.referencedBy({
139+
_type: 'io.cozy.konnectors',
140+
_id: `io.cozy.konnectors/${slug}`
141+
})
142+
)
143+
const sourceAccountIdentifier = options.sourceAccountIdentifier
144+
145+
const existingAccountFolders = existingKonnectorFolders.filter(folder =>
146+
Boolean(
147+
folder.referenced_by?.find(
148+
ref =>
149+
ref.type === 'io.cozy.accounts.sourceAccountIdentifier' &&
150+
(sourceAccountIdentifier ? ref.id === sourceAccountIdentifier : true)
151+
)
152+
)
153+
)
154+
124155
let filesArray = undefined
125156
let savedFiles = 0
126157
const savedEntries = []
@@ -162,19 +193,23 @@ const saveFiles = async (entries, fields, options = {}) => {
162193

163194
delete entry.shouldReplaceName
164195
}
165-
196+
let newEntry = { ...entry }
166197
if (canBeSaved(entry)) {
167198
const folderPath = await getOrCreateDestinationPath(
168199
entry,
169-
saveOptions
200+
saveOptions,
201+
existingAccountFolders
170202
)
171-
entry = await saveEntry(entry, { ...saveOptions, folderPath })
203+
newEntry = await saveEntry(entry, {
204+
...saveOptions,
205+
folderPath
206+
})
172207
if (entry && entry._cozy_file_to_create) {
173208
savedFiles++
174209
delete entry._cozy_file_to_create
175210
}
176211
}
177-
savedEntries.push(entry)
212+
savedEntries.push(newEntry)
178213
},
179214
{ concurrency: saveOptions.concurrency }
180215
)
@@ -384,10 +419,11 @@ async function getFileFromMetaData(
384419
async function getFileFromPath(entry, options) {
385420
try {
386421
log('debug', `Checking existence of ${getFilePath({ entry, options })}`)
387-
const result = await client
388-
.collection(FILES_DOCTYPE)
389-
.statByPath(getFilePath({ entry, options }))
390-
return result.data
422+
return (
423+
await client
424+
.collection(FILES_DOCTYPE)
425+
.statByPath(getFilePath({ entry, options }))
426+
).data
391427
} catch (err) {
392428
log('debug', err.message)
393429
return false
@@ -780,12 +816,78 @@ function getAttribute(obj, attribute) {
780816
return get(obj, `attributes.${attribute}`, get(obj, attribute))
781817
}
782818

783-
async function getOrCreateDestinationPath(entry, saveOptions) {
784-
const subPath = entry.subPath || saveOptions.subPath
785-
let finalPath = saveOptions.folderPath
786-
if (subPath) {
787-
finalPath += '/' + subPath
788-
await client.collection(FILES_DOCTYPE).createDirectoryByPath(finalPath)
819+
async function getOrCreateDestinationPath(
820+
entry,
821+
saveOptions,
822+
existingAccountFolders
823+
) {
824+
try {
825+
const subPath =
826+
entry.contract?.name ||
827+
saveOptions.contract?.name ||
828+
entry.subPath ||
829+
saveOptions.subPath
830+
const contractId = entry.contract?.id || saveOptions.contract?.id
831+
832+
let finalPath = saveOptions.folderPath
833+
if (subPath) {
834+
// first try to find dirctory by
835+
finalPath += '/' + subPath
836+
if (
837+
contractId &&
838+
existingAccountFolders.find(folder =>
839+
hasContractReference(folder, contractId)
840+
)
841+
) {
842+
return finalPath
843+
}
844+
845+
const sourceAccountIdentifier =
846+
saveOptions.sourceAccountOptions?.sourceAccountIdentifier
847+
848+
if (sourceAccountIdentifier) {
849+
const slug = manifest.data.slug
850+
const fileCollection = client.collection(FILES_DOCTYPE)
851+
const { data: folder } = await fileCollection.createDirectoryByPath(
852+
finalPath
853+
)
854+
await Promise.all([
855+
fileCollection.addReferencesTo(
856+
{
857+
_id: `io.cozy.konnectors/${slug}`,
858+
_type: 'io.cozy.konnectors'
859+
},
860+
[folder]
861+
),
862+
fileCollection.addReferencesTo(
863+
{
864+
_id: sourceAccountIdentifier,
865+
_type: 'io.cozy.accounts.sourceAccountIdentifier'
866+
},
867+
[folder]
868+
),
869+
fileCollection.addReferencesTo(
870+
{
871+
_id: contractId,
872+
_type: 'io.cozy.accounts.contracts'
873+
},
874+
[folder]
875+
)
876+
])
877+
}
878+
}
879+
return finalPath
880+
} catch (err) {
881+
throw new Error(
882+
`cozy-konnector-libs::saveFiles Error in getOrCreateDestinationPath. Cannot save files: ${err.message}`
883+
)
789884
}
790-
return finalPath
885+
}
886+
887+
function hasContractReference(folder, contractId) {
888+
return Boolean(
889+
folder.referenced_by?.find(
890+
ref => ref.type === 'io.cozy.accounts.contracts' && ref.id === contractId
891+
)
892+
)
791893
}

‎packages/cozy-konnector-libs/src/libs/saveFiles.spec.js

+172-14
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,38 @@
11
jest.mock('./cozyclient')
22
const cozyClient = require('./cozyclient')
3+
34
const save = jest.fn()
5+
const query = jest.fn().mockResolvedValue({ included: [], data: null })
46
const destroy = jest.fn()
57
const queryAll = jest.fn()
68
const statByPath = jest.fn()
79
const deleteFilePermanently = jest.fn()
810
const fetchFileContentById = jest.fn()
9-
const createDirectoryByPath = jest.fn()
11+
const createDirectoryByPath = jest
12+
.fn()
13+
.mockImplementation(path => ({ data: { _id: 'subpathid', path } }))
14+
const addReferencesTo = jest.fn()
1015
cozyClient.new = {
1116
save,
17+
query,
1218
destroy,
1319
queryAll,
1420
collection: () => ({
1521
statByPath,
1622
deleteFilePermanently,
1723
fetchFileContentById,
18-
createDirectoryByPath
24+
createDirectoryByPath,
25+
addReferencesTo
1926
})
2027
}
2128
const client = cozyClient.new
2229
const { models } = require('cozy-client')
2330
client.models = models
2431

2532
const manifest = require('./manifest')
33+
2634
const logger = require('cozy-logger')
35+
2736
const saveFiles = require('./saveFiles')
2837
const getFileIfExists = saveFiles.getFileIfExists
2938
const sanitizeFileName = saveFiles.sanitizeFileName
@@ -134,6 +143,7 @@ describe('saveFiles', function () {
134143
} = test
135144
describe(name, () => {
136145
beforeEach(async () => {
146+
query.mockResolvedValue({ included: [] })
137147
statByPath.mockImplementation(async path => {
138148
if (path === FOLDER_PATH) {
139149
return { data: { _id: 'folderId' } }
@@ -189,17 +199,21 @@ describe('saveFiles', function () {
189199
// Renaming Test, not working due to not sucessfully mock updateAttributesById
190200
describe('when entry have shouldReplaceName', () => {
191201
beforeEach(async () => {
192-
/* cozyClient.files.statByPath.mockImplementation(() => {
193-
return asyncResolve({ _id: 'folderId' })
194-
})*/
202+
statByPath.mockImplementation(async path => {
203+
// Must check if we are stating on the folder or on the file
204+
return path === FOLDER_PATH
205+
? { data: { _id: 'folderId' } }
206+
: {
207+
data: makeFile('existingFileId', {
208+
name: 'bill.pdf'
209+
})
210+
}
211+
})
212+
query.mockResolvedValue({ included: [] })
195213
queryAll.mockImplementation(() => {
196214
// Watch out, not the same format as cozyClient.files
197215
return [{ name: '201712_freemobile.pdf', _id: 'idToRename' }]
198216
})
199-
/* cozyClient.files.updateAttributesById.mockReset()
200-
cozyClient.files.updateAttributesById.mockImplementation(() => {
201-
return
202-
})*/
203217
})
204218
const billWithShouldReplaceName = [
205219
{
@@ -553,6 +567,13 @@ describe('saveFiles', function () {
553567

554568
describe('subPath handling', () => {
555569
beforeEach(function () {
570+
createDirectoryByPath.mockImplementation(path => ({
571+
data: {
572+
_id: 'subpathid',
573+
path
574+
}
575+
}))
576+
query.mockResolvedValue({ included: [] })
556577
statByPath.mockImplementation(async path => {
557578
if (path.includes('randomfileurl.txt')) {
558579
throw new Error('Anything')
@@ -575,15 +596,21 @@ describe('subPath handling', () => {
575596
expect(client.collection().createDirectoryByPath.mock.calls.length).toBe(0)
576597
})
577598
it('should change the folderPath for entries with subPath', async () => {
578-
await saveFiles([{ fileurl: 'randomfileurl.txt', subPath: 'mySubPath' }], {
579-
folderPath: 'mainPath'
580-
})
599+
await saveFiles(
600+
[{ fileurl: 'randomfileurl.txt', subPath: 'mySubPath' }],
601+
{
602+
folderPath: 'mainPath'
603+
},
604+
{
605+
sourceAccountIdentifier: 'accountidentifier'
606+
}
607+
)
581608

582609
expect(client.collection().createDirectoryByPath).toHaveBeenCalledWith(
583610
'mainPath/mySubPath'
584611
)
585612

586-
expect(client.save).toHaveBeenCalledWith(
613+
expect(save).toHaveBeenCalledWith(
587614
expect.objectContaining({
588615
name: 'randomfileurl.txt',
589616
dirId: 'mainPath/mySubPath'
@@ -596,7 +623,7 @@ describe('subPath handling', () => {
596623
{
597624
folderPath: 'mainPath'
598625
},
599-
{ subPath: 'mySubPath' }
626+
{ subPath: 'mySubPath', sourceAccountIdentifier: 'accountidentifier' }
600627
)
601628

602629
expect(client.collection().createDirectoryByPath).toHaveBeenCalledWith(
@@ -612,11 +639,142 @@ describe('subPath handling', () => {
612639
})
613640
})
614641

642+
describe('contract handling', () => {
643+
beforeEach(function () {
644+
createDirectoryByPath.mockImplementation(path => ({
645+
data: {
646+
_id: 'subpathid',
647+
path
648+
}
649+
}))
650+
query.mockResolvedValue({ included: [] })
651+
statByPath.mockImplementation(async path => {
652+
if (path.includes('randomfileurl.txt')) {
653+
throw new Error('Anything')
654+
} else {
655+
return { data: { _id: path } }
656+
}
657+
})
658+
})
659+
it('should not create subPath if no contract specified', async () => {
660+
await saveFiles([{ fileurl: 'randomfileurl.txt' }], {
661+
folderPath: 'mainPath'
662+
})
663+
664+
expect(client.save).toHaveBeenCalledWith(
665+
expect.objectContaining({
666+
name: 'randomfileurl.txt',
667+
dirId: 'mainPath'
668+
})
669+
)
670+
expect(client.collection().createDirectoryByPath.mock.calls.length).toBe(0)
671+
expect(addReferencesTo).not.toHaveBeenCalled()
672+
})
673+
it('should change the folderPath for entries with contract', async () => {
674+
manifest.data.slug = 'testslug'
675+
await saveFiles(
676+
[
677+
{
678+
fileurl: 'randomfileurl.txt',
679+
contract: { id: 'testContractId', name: 'contractPath' }
680+
}
681+
],
682+
{
683+
folderPath: 'mainPath'
684+
},
685+
{
686+
sourceAccountIdentifier: 'testSourceAccountIdentifier'
687+
}
688+
)
689+
690+
expect(client.collection().createDirectoryByPath).toHaveBeenCalledWith(
691+
'mainPath/contractPath'
692+
)
693+
694+
expect(save).toHaveBeenCalledWith(
695+
expect.objectContaining({
696+
name: 'randomfileurl.txt',
697+
dirId: 'mainPath/contractPath'
698+
})
699+
)
700+
701+
expect(addReferencesTo).toHaveBeenCalledTimes(3)
702+
expect(addReferencesTo).toHaveBeenNthCalledWith(
703+
1,
704+
{ _id: 'io.cozy.konnectors/testslug', _type: 'io.cozy.konnectors' },
705+
[{ _id: 'subpathid', path: 'mainPath/contractPath' }]
706+
)
707+
expect(addReferencesTo).toHaveBeenNthCalledWith(
708+
2,
709+
{
710+
_id: 'testSourceAccountIdentifier',
711+
_type: 'io.cozy.accounts.sourceAccountIdentifier'
712+
},
713+
[{ _id: 'subpathid', path: 'mainPath/contractPath' }]
714+
)
715+
expect(addReferencesTo).toHaveBeenNthCalledWith(
716+
3,
717+
{
718+
_id: 'testContractId',
719+
_type: 'io.cozy.accounts.contracts'
720+
},
721+
[{ _id: 'subpathid', path: 'mainPath/contractPath' }]
722+
)
723+
})
724+
it('should change the folderPath with contract main option', async () => {
725+
manifest.data.slug = 'testslug2'
726+
await saveFiles(
727+
[{ fileurl: 'randomfileurl.txt' }],
728+
{
729+
folderPath: 'mainPath'
730+
},
731+
{
732+
contract: { id: 'testContractId', name: 'contractPath' },
733+
sourceAccountIdentifier: 'testSourceAccountIdentifier'
734+
}
735+
)
736+
737+
expect(client.collection().createDirectoryByPath).toHaveBeenCalledWith(
738+
'mainPath/contractPath'
739+
)
740+
741+
expect(client.save).toHaveBeenCalledWith(
742+
expect.objectContaining({
743+
name: 'randomfileurl.txt',
744+
dirId: 'mainPath/contractPath'
745+
})
746+
)
747+
expect(addReferencesTo).toHaveBeenCalledTimes(3)
748+
expect(addReferencesTo).toHaveBeenNthCalledWith(
749+
1,
750+
{ _id: 'io.cozy.konnectors/testslug2', _type: 'io.cozy.konnectors' },
751+
[{ _id: 'subpathid', path: 'mainPath/contractPath' }]
752+
)
753+
expect(addReferencesTo).toHaveBeenNthCalledWith(
754+
2,
755+
{
756+
_id: 'testSourceAccountIdentifier',
757+
_type: 'io.cozy.accounts.sourceAccountIdentifier'
758+
},
759+
[{ _id: 'subpathid', path: 'mainPath/contractPath' }]
760+
)
761+
expect(addReferencesTo).toHaveBeenNthCalledWith(
762+
3,
763+
{
764+
_id: 'testContractId',
765+
_type: 'io.cozy.accounts.contracts'
766+
},
767+
[{ _id: 'subpathid', path: 'mainPath/contractPath' }]
768+
)
769+
})
770+
})
771+
615772
describe('getFileIfExists', function () {
616773
jest.resetAllMocks()
617774
statByPath.mockReset()
618775
describe('when in filepath mode', () => {
619776
beforeEach(function () {
777+
query.mockResolvedValue({ included: [] })
620778
manifest.data.slug = false // Without slug, force filepath mode
621779
})
622780
it('when the file does not exist, should not return any file', async () => {

0 commit comments

Comments
 (0)
Please sign in to comment.