@wbce-d9/storage-driver-cloudinary 9.26.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.js +62 -0
- package/dist/index.js +303 -0
- package/dist/index.test.js +951 -0
- package/package.json +44 -0
- package/readme.md +3 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export const IMAGE_EXTENSIONS = [
|
|
2
|
+
'.ai',
|
|
3
|
+
'.avif',
|
|
4
|
+
'.png',
|
|
5
|
+
'.webp',
|
|
6
|
+
'.bmp',
|
|
7
|
+
'.bw',
|
|
8
|
+
'.dfvu',
|
|
9
|
+
'.ps',
|
|
10
|
+
'.ept',
|
|
11
|
+
'.eps',
|
|
12
|
+
'.eps3',
|
|
13
|
+
'.fbx',
|
|
14
|
+
'.flif',
|
|
15
|
+
'.gif',
|
|
16
|
+
'.glb',
|
|
17
|
+
'.gltf',
|
|
18
|
+
'.heif',
|
|
19
|
+
'.heic',
|
|
20
|
+
'.ico',
|
|
21
|
+
'.indd',
|
|
22
|
+
'.jpg',
|
|
23
|
+
'.jpe',
|
|
24
|
+
'.jpeg',
|
|
25
|
+
'.jp2',
|
|
26
|
+
'.wdp',
|
|
27
|
+
'.jxr',
|
|
28
|
+
'.hdp',
|
|
29
|
+
'.obj',
|
|
30
|
+
'.pdf',
|
|
31
|
+
'.ply',
|
|
32
|
+
'.png',
|
|
33
|
+
'.psd',
|
|
34
|
+
'.arw',
|
|
35
|
+
'.cr2',
|
|
36
|
+
'.svg',
|
|
37
|
+
'.tga',
|
|
38
|
+
'.tif',
|
|
39
|
+
'.tiff',
|
|
40
|
+
'.u3ma',
|
|
41
|
+
'.usdz',
|
|
42
|
+
'.webp',
|
|
43
|
+
];
|
|
44
|
+
export const VIDEO_EXTENSIONS = [
|
|
45
|
+
'.3g2',
|
|
46
|
+
'.3gp',
|
|
47
|
+
'.avi',
|
|
48
|
+
'.flv',
|
|
49
|
+
'.m3u8',
|
|
50
|
+
'.ts',
|
|
51
|
+
'.m2ts',
|
|
52
|
+
'.mts',
|
|
53
|
+
'.mov',
|
|
54
|
+
'.mkv',
|
|
55
|
+
'.mp4',
|
|
56
|
+
'.mpeg',
|
|
57
|
+
'.mpd',
|
|
58
|
+
'.mxf',
|
|
59
|
+
'.ogv',
|
|
60
|
+
'.webm',
|
|
61
|
+
'.wmv',
|
|
62
|
+
];
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { normalizePath } from '@wbce-d9/utils';
|
|
2
|
+
import { Blob, Buffer } from 'node:buffer';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { extname, join, parse } from 'node:path';
|
|
5
|
+
import { Readable } from 'node:stream';
|
|
6
|
+
import PQueue from 'p-queue';
|
|
7
|
+
import { fetch, FormData } from 'undici';
|
|
8
|
+
import { IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from './constants.js';
|
|
9
|
+
export class DriverCloudinary {
|
|
10
|
+
root;
|
|
11
|
+
apiKey;
|
|
12
|
+
apiSecret;
|
|
13
|
+
cloudName;
|
|
14
|
+
accessMode;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.root = config.root ? normalizePath(config.root, { removeLeading: true }) : '';
|
|
17
|
+
this.apiKey = config.apiKey;
|
|
18
|
+
this.apiSecret = config.apiSecret;
|
|
19
|
+
this.cloudName = config.cloudName;
|
|
20
|
+
this.accessMode = config.accessMode;
|
|
21
|
+
}
|
|
22
|
+
fullPath(filepath) {
|
|
23
|
+
return normalizePath(join(this.root, filepath), { removeLeading: true });
|
|
24
|
+
}
|
|
25
|
+
toFormUrlEncoded(obj, options) {
|
|
26
|
+
let entries = Object.entries(obj);
|
|
27
|
+
if (options?.sort) {
|
|
28
|
+
entries = entries.sort(([keyA], [keyB]) => keyA.localeCompare(keyB));
|
|
29
|
+
}
|
|
30
|
+
return new URLSearchParams(entries).toString();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Generate the Cloudinary sha256 signature for the given payload
|
|
34
|
+
* @see https://cloudinary.com/documentation/signatures
|
|
35
|
+
*/
|
|
36
|
+
getFullSignature(payload) {
|
|
37
|
+
const denylist = ['file', 'cloud_name', 'resource_type', 'api_key'];
|
|
38
|
+
const signaturePayload = Object.fromEntries(Object.entries(payload).filter(([key]) => denylist.includes(key) === false));
|
|
39
|
+
const signaturePayloadString = this.toFormUrlEncoded(signaturePayload, { sort: true });
|
|
40
|
+
return createHash('sha256')
|
|
41
|
+
.update(signaturePayloadString + this.apiSecret)
|
|
42
|
+
.digest('hex');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Creates inline URL signature for use with the image reading API
|
|
46
|
+
* @see https://cloudinary.com/documentation/advanced_url_delivery_options#generating_delivery_url_signatures
|
|
47
|
+
*/
|
|
48
|
+
getParameterSignature(filepath) {
|
|
49
|
+
return `s--${createHash('sha256')
|
|
50
|
+
.update(filepath + this.apiSecret)
|
|
51
|
+
.digest('base64url')
|
|
52
|
+
.substring(0, 8)}--`;
|
|
53
|
+
}
|
|
54
|
+
getTimestamp() {
|
|
55
|
+
return String(new Date().getTime());
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Used to guess what resource type is appropriate for a given filepath
|
|
59
|
+
* @see https://cloudinary.com/documentation/image_transformations#image_upload_note
|
|
60
|
+
*/
|
|
61
|
+
getResourceType(filepath) {
|
|
62
|
+
const fileExtension = extname(filepath);
|
|
63
|
+
if (IMAGE_EXTENSIONS.includes(fileExtension))
|
|
64
|
+
return 'image';
|
|
65
|
+
if (VIDEO_EXTENSIONS.includes(fileExtension))
|
|
66
|
+
return 'video';
|
|
67
|
+
return 'raw';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* For Cloudinary Admin APIs, the file extension needs to be omitted for images and videos. Raw
|
|
71
|
+
* on the other hand requires the extension to be present.
|
|
72
|
+
*/
|
|
73
|
+
getPublicId(filepath) {
|
|
74
|
+
const resourceType = this.getResourceType(filepath);
|
|
75
|
+
if (resourceType === 'raw')
|
|
76
|
+
return filepath;
|
|
77
|
+
return parse(filepath).name;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Generates the Authorization header value for Cloudinary's basic auth endpoints
|
|
81
|
+
*/
|
|
82
|
+
getBasicAuth() {
|
|
83
|
+
const creds = `${this.apiKey}:${this.apiSecret}`;
|
|
84
|
+
const base64 = Buffer.from(creds).toString('base64');
|
|
85
|
+
return `Basic ${base64}`;
|
|
86
|
+
}
|
|
87
|
+
async read(filepath, range) {
|
|
88
|
+
const resourceType = this.getResourceType(filepath);
|
|
89
|
+
const fullPath = this.fullPath(filepath);
|
|
90
|
+
const signature = this.getParameterSignature(fullPath);
|
|
91
|
+
const url = `https://res.cloudinary.com/${this.cloudName}/${resourceType}/upload/${signature}/${fullPath}`;
|
|
92
|
+
const requestInit = { method: 'GET' };
|
|
93
|
+
if (range) {
|
|
94
|
+
requestInit.headers = {
|
|
95
|
+
Range: `bytes=${range.start ?? ''}-${range.end ?? ''}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const response = await fetch(url, requestInit);
|
|
99
|
+
if (response.status >= 400 || !response.body) {
|
|
100
|
+
throw new Error(`No stream returned for file "${filepath}"`);
|
|
101
|
+
}
|
|
102
|
+
return Readable.fromWeb(response.body);
|
|
103
|
+
}
|
|
104
|
+
async stat(filepath) {
|
|
105
|
+
const fullPath = this.fullPath(filepath);
|
|
106
|
+
const resourceType = this.getResourceType(fullPath);
|
|
107
|
+
const publicId = this.getPublicId(fullPath);
|
|
108
|
+
const parameters = {
|
|
109
|
+
public_id: publicId,
|
|
110
|
+
type: 'upload',
|
|
111
|
+
api_key: this.apiKey,
|
|
112
|
+
timestamp: this.getTimestamp(),
|
|
113
|
+
};
|
|
114
|
+
const signature = this.getFullSignature(parameters);
|
|
115
|
+
const body = this.toFormUrlEncoded({
|
|
116
|
+
...parameters,
|
|
117
|
+
signature,
|
|
118
|
+
});
|
|
119
|
+
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/explicit`;
|
|
120
|
+
const response = await fetch(url, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
124
|
+
},
|
|
125
|
+
body,
|
|
126
|
+
});
|
|
127
|
+
if (response.status >= 400) {
|
|
128
|
+
throw new Error(`No stat returned for file "${filepath}"`);
|
|
129
|
+
}
|
|
130
|
+
const { bytes, created_at } = (await response.json());
|
|
131
|
+
return { size: bytes, modified: new Date(created_at) };
|
|
132
|
+
}
|
|
133
|
+
async exists(filepath) {
|
|
134
|
+
try {
|
|
135
|
+
await this.stat(filepath);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async move(src, dest) {
|
|
143
|
+
const fullSrc = this.fullPath(src);
|
|
144
|
+
const fullDest = this.fullPath(dest);
|
|
145
|
+
const resourceType = this.getResourceType(fullSrc);
|
|
146
|
+
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/rename`;
|
|
147
|
+
const parameters = {
|
|
148
|
+
from_public_id: this.getPublicId(fullSrc),
|
|
149
|
+
to_public_id: this.getPublicId(fullDest),
|
|
150
|
+
api_key: this.apiKey,
|
|
151
|
+
timestamp: this.getTimestamp(),
|
|
152
|
+
};
|
|
153
|
+
const signature = this.getFullSignature(parameters);
|
|
154
|
+
const body = this.toFormUrlEncoded({
|
|
155
|
+
...parameters,
|
|
156
|
+
signature,
|
|
157
|
+
});
|
|
158
|
+
const response = await fetch(url, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
162
|
+
},
|
|
163
|
+
body,
|
|
164
|
+
});
|
|
165
|
+
if (response.status >= 400) {
|
|
166
|
+
const responseData = (await response.json());
|
|
167
|
+
throw new Error(`Can't move file "${src}": ${responseData?.error?.message ?? 'Unknown'}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async copy(src, dest) {
|
|
171
|
+
const stream = await this.read(src);
|
|
172
|
+
await this.write(dest, stream);
|
|
173
|
+
}
|
|
174
|
+
async write(filepath, content) {
|
|
175
|
+
const fullPath = this.fullPath(filepath);
|
|
176
|
+
const resourceType = this.getResourceType(fullPath);
|
|
177
|
+
const uploadParameters = {
|
|
178
|
+
timestamp: this.getTimestamp(),
|
|
179
|
+
api_key: this.apiKey,
|
|
180
|
+
type: 'upload',
|
|
181
|
+
access_mode: this.accessMode,
|
|
182
|
+
public_id: this.getPublicId(fullPath),
|
|
183
|
+
};
|
|
184
|
+
const signature = this.getFullSignature(uploadParameters);
|
|
185
|
+
let currentChunkSize = 0;
|
|
186
|
+
let totalSize = 0;
|
|
187
|
+
let uploaded = 0;
|
|
188
|
+
let error = null;
|
|
189
|
+
const queue = new PQueue({ concurrency: 10 });
|
|
190
|
+
const chunks = [];
|
|
191
|
+
for await (const chunk of content) {
|
|
192
|
+
chunks.push(chunk);
|
|
193
|
+
currentChunkSize += chunk.length;
|
|
194
|
+
// Cloudinary requires each chunk to be at least 5MB. We'll submit the chunk as soon as we
|
|
195
|
+
// reach 5.5MB to be safe
|
|
196
|
+
if (currentChunkSize >= 5.5e6) {
|
|
197
|
+
const uploadChunkParams = {
|
|
198
|
+
resourceType,
|
|
199
|
+
blob: new Blob(chunks),
|
|
200
|
+
bytesOffset: uploaded,
|
|
201
|
+
bytesTotal: -1,
|
|
202
|
+
parameters: {
|
|
203
|
+
signature,
|
|
204
|
+
...uploadParameters,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
queue
|
|
208
|
+
.add(() => this.uploadChunk(uploadChunkParams))
|
|
209
|
+
.catch((err) => {
|
|
210
|
+
error = err;
|
|
211
|
+
});
|
|
212
|
+
uploaded += currentChunkSize;
|
|
213
|
+
currentChunkSize = 0;
|
|
214
|
+
chunks.length = 0; // empty the array of chunks
|
|
215
|
+
}
|
|
216
|
+
totalSize += chunk.length;
|
|
217
|
+
}
|
|
218
|
+
queue
|
|
219
|
+
.add(() => this.uploadChunk({
|
|
220
|
+
resourceType,
|
|
221
|
+
blob: new Blob(chunks),
|
|
222
|
+
bytesOffset: uploaded,
|
|
223
|
+
bytesTotal: totalSize,
|
|
224
|
+
parameters: {
|
|
225
|
+
signature,
|
|
226
|
+
...uploadParameters,
|
|
227
|
+
},
|
|
228
|
+
}))
|
|
229
|
+
.catch((err) => {
|
|
230
|
+
error = err;
|
|
231
|
+
});
|
|
232
|
+
await queue.onIdle();
|
|
233
|
+
if (error) {
|
|
234
|
+
throw new Error(`Can't upload file "${filepath}": ${error.message}`, { cause: error });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async uploadChunk({ resourceType, blob, bytesOffset, bytesTotal, parameters, }) {
|
|
238
|
+
const formData = new FormData();
|
|
239
|
+
formData.set('file', blob);
|
|
240
|
+
for (const [key, value] of Object.entries(parameters)) {
|
|
241
|
+
formData.set(key, value);
|
|
242
|
+
}
|
|
243
|
+
const response = await fetch(`https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/upload`, {
|
|
244
|
+
method: 'POST',
|
|
245
|
+
body: formData,
|
|
246
|
+
headers: {
|
|
247
|
+
/** @see https://support.cloudinary.com/hc/en-us/articles/208263735-Guidelines-for-self-implementing-chunked-upload-to-Cloudinary */
|
|
248
|
+
'X-Unique-Upload-Id': parameters.timestamp,
|
|
249
|
+
'Content-Range': `bytes ${bytesOffset}-${bytesOffset + blob.size - 1}/${bytesTotal}`,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
if (response.status >= 400) {
|
|
253
|
+
const responseData = (await response.json());
|
|
254
|
+
throw new Error(responseData?.error?.message ?? 'Unknown');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async delete(filepath) {
|
|
258
|
+
const fullPath = this.fullPath(filepath);
|
|
259
|
+
const resourceType = this.getResourceType(fullPath);
|
|
260
|
+
const publicId = this.getPublicId(fullPath);
|
|
261
|
+
const url = `https://api.cloudinary.com/v1_1/${this.cloudName}/${resourceType}/destroy`;
|
|
262
|
+
const parameters = {
|
|
263
|
+
timestamp: this.getTimestamp(),
|
|
264
|
+
api_key: this.apiKey,
|
|
265
|
+
resource_type: resourceType,
|
|
266
|
+
public_id: publicId,
|
|
267
|
+
};
|
|
268
|
+
const signature = this.getFullSignature(parameters);
|
|
269
|
+
await fetch(url, {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
body: this.toFormUrlEncoded({
|
|
272
|
+
...parameters,
|
|
273
|
+
signature,
|
|
274
|
+
}),
|
|
275
|
+
headers: {
|
|
276
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async *list(prefix = '') {
|
|
281
|
+
const fullPath = this.fullPath(prefix);
|
|
282
|
+
const publicId = this.getPublicId(fullPath);
|
|
283
|
+
let nextCursor = '';
|
|
284
|
+
do {
|
|
285
|
+
const response = await fetch(`https://api.cloudinary.com/v1_1/${this.cloudName}/resources/search?expression=${publicId}*&next_cursor=${nextCursor}`, {
|
|
286
|
+
method: 'GET',
|
|
287
|
+
headers: {
|
|
288
|
+
Authorization: this.getBasicAuth(),
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
const json = (await response.json());
|
|
292
|
+
if (response.status >= 400) {
|
|
293
|
+
const responseData = (await response.json());
|
|
294
|
+
throw new Error(`Can't list for prefix "${prefix}": ${responseData?.error?.message ?? 'Unknown'}`);
|
|
295
|
+
}
|
|
296
|
+
nextCursor = json.next_cursor;
|
|
297
|
+
for (const file of json.resources) {
|
|
298
|
+
yield file.public_id;
|
|
299
|
+
}
|
|
300
|
+
} while (nextCursor);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
export default DriverCloudinary;
|
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
import { normalizePath } from '@wbce-d9/utils';
|
|
2
|
+
import { rand, randAlphaNumeric, randDirectoryPath, randFilePath, randFileType, randGitBranch as randCloudName, randGitCommitSha as randSha, randGitShortSha as randUnique, randNumber, randPastDate, randText, randWord, } from '@ngneat/falso';
|
|
3
|
+
import { Blob } from 'node:buffer';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { extname, join, parse } from 'node:path';
|
|
6
|
+
import { PassThrough, Readable } from 'node:stream';
|
|
7
|
+
import { ReadableStream } from 'node:stream/web';
|
|
8
|
+
import { fetch, FormData } from 'undici';
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
10
|
+
import { IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from './constants.js';
|
|
11
|
+
import { DriverCloudinary } from './index.js';
|
|
12
|
+
vi.mock('@wbce-d9/utils/node');
|
|
13
|
+
vi.mock('@wbce-d9/utils');
|
|
14
|
+
vi.mock('node:path');
|
|
15
|
+
vi.mock('node:crypto');
|
|
16
|
+
vi.mock('undici');
|
|
17
|
+
let sample;
|
|
18
|
+
let driver;
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
sample = {
|
|
21
|
+
config: {
|
|
22
|
+
root: randDirectoryPath(),
|
|
23
|
+
apiKey: randNumber({ length: 15 }).join(''),
|
|
24
|
+
apiSecret: randAlphaNumeric({ length: 27 }).join(''),
|
|
25
|
+
cloudName: randCloudName(),
|
|
26
|
+
accessMode: rand(['public', 'authenticated']),
|
|
27
|
+
},
|
|
28
|
+
path: {
|
|
29
|
+
input: randUnique() + randFilePath(),
|
|
30
|
+
inputFull: randUnique() + randFilePath(),
|
|
31
|
+
src: randUnique() + randFilePath(),
|
|
32
|
+
srcFull: randUnique() + randFilePath(),
|
|
33
|
+
dest: randUnique() + randFilePath(),
|
|
34
|
+
destFull: randUnique() + randFilePath(),
|
|
35
|
+
},
|
|
36
|
+
range: {
|
|
37
|
+
start: randNumber(),
|
|
38
|
+
end: randNumber(),
|
|
39
|
+
},
|
|
40
|
+
stream: new PassThrough(),
|
|
41
|
+
text: randText(),
|
|
42
|
+
file: {
|
|
43
|
+
type: randFileType(),
|
|
44
|
+
size: randNumber(),
|
|
45
|
+
modified: randPastDate(),
|
|
46
|
+
},
|
|
47
|
+
resourceType: rand(['image', 'video', 'raw']),
|
|
48
|
+
publicId: {
|
|
49
|
+
input: randUnique() + randFilePath(),
|
|
50
|
+
src: randUnique() + randFilePath(),
|
|
51
|
+
dest: randUnique() + randFilePath(),
|
|
52
|
+
},
|
|
53
|
+
parameterSignature: `s--${randAlphaNumeric({ length: 8 }).join('')}--`,
|
|
54
|
+
fullSignature: randSha(),
|
|
55
|
+
basicAuth: `Basic ${randSha()}`,
|
|
56
|
+
timestamp: String(randPastDate().getTime()),
|
|
57
|
+
formUrlEncoded: randAlphaNumeric({ length: 30 }).join(''),
|
|
58
|
+
};
|
|
59
|
+
driver = new DriverCloudinary({
|
|
60
|
+
cloudName: sample.config.cloudName,
|
|
61
|
+
apiKey: sample.config.apiKey,
|
|
62
|
+
apiSecret: sample.config.apiSecret,
|
|
63
|
+
accessMode: sample.config.accessMode,
|
|
64
|
+
});
|
|
65
|
+
driver['fullPath'] = vi.fn().mockImplementation((input) => {
|
|
66
|
+
if (input === sample.path.src)
|
|
67
|
+
return sample.path.srcFull;
|
|
68
|
+
if (input === sample.path.dest)
|
|
69
|
+
return sample.path.destFull;
|
|
70
|
+
if (input === sample.path.input)
|
|
71
|
+
return sample.path.inputFull;
|
|
72
|
+
return '';
|
|
73
|
+
});
|
|
74
|
+
driver['getPublicId'] = vi.fn().mockImplementation((input) => {
|
|
75
|
+
if (input === sample.path.srcFull)
|
|
76
|
+
return sample.publicId.src;
|
|
77
|
+
if (input === sample.path.destFull)
|
|
78
|
+
return sample.publicId.dest;
|
|
79
|
+
if (input === sample.path.inputFull)
|
|
80
|
+
return sample.publicId.input;
|
|
81
|
+
return '';
|
|
82
|
+
});
|
|
83
|
+
driver['getResourceType'] = vi.fn().mockReturnValue(sample.resourceType);
|
|
84
|
+
driver['getParameterSignature'] = vi.fn().mockReturnValue(sample.parameterSignature);
|
|
85
|
+
driver['getBasicAuth'] = vi.fn().mockReturnValue(sample.basicAuth);
|
|
86
|
+
driver['getFullSignature'] = vi.fn().mockReturnValue(sample.fullSignature);
|
|
87
|
+
driver['getTimestamp'] = vi.fn().mockReturnValue(sample.timestamp);
|
|
88
|
+
driver['toFormUrlEncoded'] = vi.fn().mockReturnValue(sample.formUrlEncoded);
|
|
89
|
+
});
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
vi.resetAllMocks();
|
|
92
|
+
});
|
|
93
|
+
describe('#constructor', () => {
|
|
94
|
+
test('Saves apiKey internally', () => {
|
|
95
|
+
expect(driver['apiKey']).toBe(sample.config.apiKey);
|
|
96
|
+
});
|
|
97
|
+
test('Saves apiSecret internally', () => {
|
|
98
|
+
expect(driver['apiSecret']).toBe(sample.config.apiSecret);
|
|
99
|
+
});
|
|
100
|
+
test('Saves cloudName internally', () => {
|
|
101
|
+
expect(driver['cloudName']).toBe(sample.config.cloudName);
|
|
102
|
+
});
|
|
103
|
+
test('Saves accessMode internally', () => {
|
|
104
|
+
expect(driver['accessMode']).toBe(sample.config.accessMode);
|
|
105
|
+
});
|
|
106
|
+
test('Defaults root to empty string', () => {
|
|
107
|
+
expect(driver['root']).toBe('');
|
|
108
|
+
});
|
|
109
|
+
test('Normalizes config path when root is given', () => {
|
|
110
|
+
vi.mocked(normalizePath).mockReturnValue(sample.path.inputFull);
|
|
111
|
+
new DriverCloudinary({
|
|
112
|
+
cloudName: sample.config.cloudName,
|
|
113
|
+
apiKey: sample.config.apiKey,
|
|
114
|
+
apiSecret: sample.config.apiSecret,
|
|
115
|
+
root: sample.config.root,
|
|
116
|
+
accessMode: sample.config.accessMode,
|
|
117
|
+
});
|
|
118
|
+
expect(normalizePath).toHaveBeenCalledWith(sample.config.root, { removeLeading: true });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
describe('#fullPath', () => {
|
|
122
|
+
test('Returns normalized joined path', () => {
|
|
123
|
+
vi.mocked(join).mockReturnValue(sample.path.inputFull);
|
|
124
|
+
vi.mocked(normalizePath).mockReturnValue(sample.path.inputFull);
|
|
125
|
+
const driver = new DriverCloudinary({
|
|
126
|
+
cloudName: sample.config.cloudName,
|
|
127
|
+
apiKey: sample.config.apiKey,
|
|
128
|
+
apiSecret: sample.config.apiSecret,
|
|
129
|
+
accessMode: sample.config.accessMode,
|
|
130
|
+
});
|
|
131
|
+
driver['root'] = sample.config.root;
|
|
132
|
+
const result = driver['fullPath'](sample.path.input);
|
|
133
|
+
expect(join).toHaveBeenCalledWith(sample.config.root, sample.path.input);
|
|
134
|
+
expect(normalizePath).toHaveBeenCalledWith(sample.path.inputFull, { removeLeading: true });
|
|
135
|
+
expect(result).toBe(sample.path.inputFull);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe('#toFormUrlEncoded', () => {
|
|
139
|
+
let mockProps;
|
|
140
|
+
let mockValues;
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
driver = new DriverCloudinary({
|
|
143
|
+
apiKey: sample.config.apiKey,
|
|
144
|
+
apiSecret: sample.config.apiSecret,
|
|
145
|
+
cloudName: sample.config.cloudName,
|
|
146
|
+
accessMode: sample.config.accessMode,
|
|
147
|
+
});
|
|
148
|
+
mockProps = Array.from(Array(3), () => randAlphaNumeric({ length: randNumber({ min: 2, max: 15 }) }).join(''));
|
|
149
|
+
mockValues = randWord({ length: 3 });
|
|
150
|
+
});
|
|
151
|
+
test('Parses plain object of strings', () => {
|
|
152
|
+
const result = driver['toFormUrlEncoded']({
|
|
153
|
+
[mockProps[0]]: mockValues[0],
|
|
154
|
+
[mockProps[1]]: mockValues[1],
|
|
155
|
+
[mockProps[2]]: mockValues[2],
|
|
156
|
+
});
|
|
157
|
+
// The order isn't guaranteed
|
|
158
|
+
expect(result).toContain(`${mockProps[0]}=${mockValues[0]}`);
|
|
159
|
+
expect(result).toContain(`${mockProps[1]}=${mockValues[1]}`);
|
|
160
|
+
expect(result).toContain(`${mockProps[2]}=${mockValues[2]}`);
|
|
161
|
+
});
|
|
162
|
+
test('Optionally sorts the properties alphabetically', () => {
|
|
163
|
+
// Expected order should be 2-0-1
|
|
164
|
+
mockProps[0] = `b_${mockProps[0]}`;
|
|
165
|
+
mockProps[1] = `c_${mockProps[1]}`;
|
|
166
|
+
mockProps[2] = `a_${mockProps[2]}`;
|
|
167
|
+
expect(driver['toFormUrlEncoded']({
|
|
168
|
+
[mockProps[0]]: mockValues[0],
|
|
169
|
+
[mockProps[1]]: mockValues[1],
|
|
170
|
+
[mockProps[2]]: mockValues[2],
|
|
171
|
+
}, { sort: true })).toBe(`${mockProps[2]}=${mockValues[2]}&${mockProps[0]}=${mockValues[0]}&${mockProps[1]}=${mockValues[1]}`);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
describe('#getFullSignature', () => {
|
|
175
|
+
let mockPayload;
|
|
176
|
+
let mockCreateHash;
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
driver = new DriverCloudinary({
|
|
179
|
+
apiKey: sample.config.apiKey,
|
|
180
|
+
apiSecret: sample.config.apiSecret,
|
|
181
|
+
cloudName: sample.config.cloudName,
|
|
182
|
+
accessMode: sample.config.accessMode,
|
|
183
|
+
});
|
|
184
|
+
mockCreateHash = {
|
|
185
|
+
update: vi.fn().mockReturnThis(),
|
|
186
|
+
digest: vi.fn().mockReturnThis(),
|
|
187
|
+
};
|
|
188
|
+
vi.mocked(createHash).mockReturnValue(mockCreateHash);
|
|
189
|
+
driver['toFormUrlEncoded'] = vi.fn();
|
|
190
|
+
const randLength = randNumber({ min: 1, max: 10 });
|
|
191
|
+
const props = randWord({ length: randLength });
|
|
192
|
+
const values = randWord({ length: randLength });
|
|
193
|
+
mockPayload = Object.fromEntries(props.map((key, index) => [key, values[index]]));
|
|
194
|
+
});
|
|
195
|
+
test('Ignores Cloudinary denylist of keys', () => {
|
|
196
|
+
const payload = {
|
|
197
|
+
...mockPayload,
|
|
198
|
+
// Ignored properties:
|
|
199
|
+
file: randText(),
|
|
200
|
+
cloud_name: randCloudName(),
|
|
201
|
+
resource_type: randWord(),
|
|
202
|
+
api_key: randAlphaNumeric({ length: 15 }).join(''),
|
|
203
|
+
};
|
|
204
|
+
driver['getFullSignature'](payload);
|
|
205
|
+
expect(driver['toFormUrlEncoded']).toHaveBeenCalledWith(mockPayload, { sort: true });
|
|
206
|
+
});
|
|
207
|
+
test('Creates sha256 hash', () => {
|
|
208
|
+
driver['getFullSignature'](mockPayload);
|
|
209
|
+
expect(createHash).toHaveBeenCalledWith('sha256');
|
|
210
|
+
});
|
|
211
|
+
test('Updates sha256 hash with signature payload + api secret', () => {
|
|
212
|
+
const mockFormUrlEncoded = randWord();
|
|
213
|
+
vi.mocked(driver['toFormUrlEncoded']).mockReturnValue(mockFormUrlEncoded);
|
|
214
|
+
driver['getFullSignature'](mockPayload);
|
|
215
|
+
expect(mockCreateHash.update).toHaveBeenCalledWith(mockFormUrlEncoded + sample.config.apiSecret);
|
|
216
|
+
});
|
|
217
|
+
test('Digests hash as hex', () => {
|
|
218
|
+
driver['getFullSignature'](mockPayload);
|
|
219
|
+
expect(mockCreateHash.digest).toHaveBeenCalledWith('hex');
|
|
220
|
+
});
|
|
221
|
+
test('Returns digested hash', () => {
|
|
222
|
+
const mockHash = randSha();
|
|
223
|
+
mockCreateHash.digest.mockReturnValue(mockHash);
|
|
224
|
+
const hash = driver['getFullSignature'](mockPayload);
|
|
225
|
+
expect(hash).toBe(mockHash);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
describe('#getParameterSignature', () => {
|
|
229
|
+
let mockHash;
|
|
230
|
+
let result;
|
|
231
|
+
let mockCreateHash;
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
driver = new DriverCloudinary({
|
|
234
|
+
apiKey: sample.config.apiKey,
|
|
235
|
+
apiSecret: sample.config.apiSecret,
|
|
236
|
+
cloudName: sample.config.cloudName,
|
|
237
|
+
accessMode: sample.config.accessMode,
|
|
238
|
+
});
|
|
239
|
+
mockHash = randSha();
|
|
240
|
+
mockCreateHash = {
|
|
241
|
+
update: vi.fn().mockReturnThis(),
|
|
242
|
+
digest: vi.fn().mockReturnValue(mockHash),
|
|
243
|
+
};
|
|
244
|
+
vi.mocked(createHash).mockReturnValue(mockCreateHash);
|
|
245
|
+
result = driver['getParameterSignature'](sample.path.input);
|
|
246
|
+
});
|
|
247
|
+
test('Creates sha256 hash', () => {
|
|
248
|
+
expect(createHash).toHaveBeenCalledWith('sha256');
|
|
249
|
+
});
|
|
250
|
+
test('Updates hash with passed filepath + apiSecret', () => {
|
|
251
|
+
expect(mockCreateHash.update).toHaveBeenCalledWith(sample.path.input + sample.config.apiSecret);
|
|
252
|
+
});
|
|
253
|
+
test('Digests hash to base64url', () => {
|
|
254
|
+
expect(mockCreateHash.digest).toHaveBeenCalledWith('base64url');
|
|
255
|
+
});
|
|
256
|
+
test('Returns first 8 characters of base64 sha hash wrapped in Cloudinary prefix/suffix', () => {
|
|
257
|
+
expect(result).toBe(`s--${mockHash.substring(0, 8)}--`);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
describe('#getTimestamp', () => {
|
|
261
|
+
let mockDate;
|
|
262
|
+
beforeEach(() => {
|
|
263
|
+
driver = new DriverCloudinary({
|
|
264
|
+
apiKey: sample.config.apiKey,
|
|
265
|
+
apiSecret: sample.config.apiSecret,
|
|
266
|
+
cloudName: sample.config.cloudName,
|
|
267
|
+
accessMode: sample.config.accessMode,
|
|
268
|
+
});
|
|
269
|
+
mockDate = randPastDate();
|
|
270
|
+
vi.useFakeTimers();
|
|
271
|
+
vi.setSystemTime(mockDate);
|
|
272
|
+
});
|
|
273
|
+
test('Returns unix timestamp for current time', () => {
|
|
274
|
+
expect(driver['getTimestamp']()).toBe(String(mockDate.getTime()));
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
describe('#getResourceType', () => {
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
driver = new DriverCloudinary({
|
|
280
|
+
apiKey: sample.config.apiKey,
|
|
281
|
+
apiSecret: sample.config.apiSecret,
|
|
282
|
+
cloudName: sample.config.cloudName,
|
|
283
|
+
accessMode: sample.config.accessMode,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
test('Returns "image" for extensions contained in the image extensions constant', () => {
|
|
287
|
+
IMAGE_EXTENSIONS.forEach((ext) => {
|
|
288
|
+
vi.mocked(extname).mockReturnValue(ext);
|
|
289
|
+
const result = driver['getResourceType'](sample.path.inputFull);
|
|
290
|
+
expect(extname).toHaveBeenCalledWith(sample.path.inputFull);
|
|
291
|
+
expect(result).toBe('image');
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
test('Returns "video" for extensions contained in the video extensions constant', () => {
|
|
295
|
+
VIDEO_EXTENSIONS.forEach((ext) => {
|
|
296
|
+
vi.mocked(extname).mockReturnValue(ext);
|
|
297
|
+
const result = driver['getResourceType'](sample.path.inputFull);
|
|
298
|
+
expect(extname).toHaveBeenCalledWith(sample.path.inputFull);
|
|
299
|
+
expect(result).toBe('video');
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
test('Returns "raw" for unknown / other extensions', () => {
|
|
303
|
+
randWord({ length: 5 }).forEach((filepath) => expect(driver['getResourceType'](filepath)).toBe('raw'));
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
describe('#getPublicId', () => {
|
|
307
|
+
let mockParsedPath;
|
|
308
|
+
beforeEach(() => {
|
|
309
|
+
driver = new DriverCloudinary({
|
|
310
|
+
apiKey: sample.config.apiKey,
|
|
311
|
+
apiSecret: sample.config.apiSecret,
|
|
312
|
+
cloudName: sample.config.cloudName,
|
|
313
|
+
accessMode: sample.config.accessMode,
|
|
314
|
+
});
|
|
315
|
+
driver['getResourceType'] = vi.fn().mockReturnValue(sample.resourceType);
|
|
316
|
+
mockParsedPath = randDirectoryPath();
|
|
317
|
+
vi.mocked(parse).mockReturnValue({ name: mockParsedPath });
|
|
318
|
+
});
|
|
319
|
+
test('Gets resourceType for given filepath', () => {
|
|
320
|
+
driver['getPublicId'](sample.path.input);
|
|
321
|
+
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.input);
|
|
322
|
+
});
|
|
323
|
+
test('Returns original file path if type is raw', () => {
|
|
324
|
+
driver['getResourceType'] = vi.fn().mockReturnValue('raw');
|
|
325
|
+
const publicId = driver['getPublicId'](sample.path.input);
|
|
326
|
+
expect(publicId).toBe(sample.path.input);
|
|
327
|
+
});
|
|
328
|
+
test('Parsed base path if other type', () => {
|
|
329
|
+
driver['getResourceType'] = vi.fn().mockReturnValue(rand(['image', 'video']));
|
|
330
|
+
const publicId = driver['getPublicId'](sample.path.input);
|
|
331
|
+
expect(publicId).toBe(mockParsedPath);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
describe('#getBasicAuth', () => {
|
|
335
|
+
let mockToString;
|
|
336
|
+
beforeEach(() => {
|
|
337
|
+
driver = new DriverCloudinary({
|
|
338
|
+
apiKey: sample.config.apiKey,
|
|
339
|
+
apiSecret: sample.config.apiSecret,
|
|
340
|
+
cloudName: sample.config.cloudName,
|
|
341
|
+
accessMode: sample.config.accessMode,
|
|
342
|
+
});
|
|
343
|
+
mockToString = vi.fn();
|
|
344
|
+
vi.spyOn(Buffer, 'from').mockReturnValue({ toString: mockToString });
|
|
345
|
+
});
|
|
346
|
+
test('Creates base64 hash of key:secret', () => {
|
|
347
|
+
driver['getBasicAuth']();
|
|
348
|
+
expect(Buffer.from).toHaveBeenCalledWith(`${sample.config.apiKey}:${sample.config.apiSecret}`);
|
|
349
|
+
expect(mockToString).toHaveBeenCalledWith('base64');
|
|
350
|
+
});
|
|
351
|
+
test(`Returns 'Basic <base64>'`, () => {
|
|
352
|
+
const mockBase64 = randSha();
|
|
353
|
+
mockToString.mockReturnValue(mockBase64);
|
|
354
|
+
const result = driver['getBasicAuth']();
|
|
355
|
+
expect(result).toBe(`Basic ${mockBase64}`);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
describe('#read', () => {
|
|
359
|
+
let mockResponse;
|
|
360
|
+
beforeEach(() => {
|
|
361
|
+
mockResponse = {
|
|
362
|
+
status: 200,
|
|
363
|
+
body: new ReadableStream(),
|
|
364
|
+
};
|
|
365
|
+
vi.mocked(fetch).mockResolvedValue(mockResponse);
|
|
366
|
+
vi.spyOn(Readable, 'fromWeb').mockReturnValue(sample.stream);
|
|
367
|
+
});
|
|
368
|
+
test('Gets resource type for extension of given filepath', async () => {
|
|
369
|
+
await driver.read(sample.path.input);
|
|
370
|
+
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.input);
|
|
371
|
+
});
|
|
372
|
+
test('Creates signature for full filepath', async () => {
|
|
373
|
+
await driver.read(sample.path.input);
|
|
374
|
+
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
|
|
375
|
+
expect(driver['getParameterSignature']).toHaveBeenCalledWith(sample.path.inputFull);
|
|
376
|
+
});
|
|
377
|
+
test('Calls fetch with generated URL', async () => {
|
|
378
|
+
await driver.read(sample.path.input);
|
|
379
|
+
expect(fetch).toHaveBeenCalledWith(`https://res.cloudinary.com/${sample.config.cloudName}/${sample.resourceType}/upload/${sample.parameterSignature}/${sample.path.inputFull}`, { method: 'GET' });
|
|
380
|
+
});
|
|
381
|
+
test('Adds optional Range header for start', async () => {
|
|
382
|
+
await driver.read(sample.path.input, { start: sample.range.start });
|
|
383
|
+
expect(fetch).toHaveBeenCalledWith(`https://res.cloudinary.com/${sample.config.cloudName}/${sample.resourceType}/upload/${sample.parameterSignature}/${sample.path.inputFull}`, { method: 'GET', headers: { Range: `bytes=${sample.range.start}-` } });
|
|
384
|
+
});
|
|
385
|
+
test('Adds optional Range header for end', async () => {
|
|
386
|
+
await driver.read(sample.path.input, { end: sample.range.end });
|
|
387
|
+
expect(fetch).toHaveBeenCalledWith(`https://res.cloudinary.com/${sample.config.cloudName}/${sample.resourceType}/upload/${sample.parameterSignature}/${sample.path.inputFull}`, { method: 'GET', headers: { Range: `bytes=-${sample.range.end}` } });
|
|
388
|
+
});
|
|
389
|
+
test('Adds optional Range header for start and end', async () => {
|
|
390
|
+
await driver.read(sample.path.input, sample.range);
|
|
391
|
+
expect(fetch).toHaveBeenCalledWith(`https://res.cloudinary.com/${sample.config.cloudName}/${sample.resourceType}/upload/${sample.parameterSignature}/${sample.path.inputFull}`, { method: 'GET', headers: { Range: `bytes=${sample.range.start}-${sample.range.end}` } });
|
|
392
|
+
});
|
|
393
|
+
test('Throws error when response has status >= 400', async () => {
|
|
394
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
395
|
+
try {
|
|
396
|
+
await driver.read(sample.path.input);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
expect(err).toBeInstanceOf(Error);
|
|
400
|
+
expect(err.message).toBe(`No stream returned for file "${sample.path.input}"`);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
test('Throws error when response has no readable body', async () => {
|
|
404
|
+
mockResponse.body = null;
|
|
405
|
+
try {
|
|
406
|
+
await driver.read(sample.path.input);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
expect(err).toBeInstanceOf(Error);
|
|
410
|
+
expect(err.message).toBe(`No stream returned for file "${sample.path.input}"`);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
test('Returns readable stream from web stream', async () => {
|
|
414
|
+
const stream = await driver.read(sample.path.input);
|
|
415
|
+
expect(Readable.fromWeb).toHaveBeenCalledWith(mockResponse.body);
|
|
416
|
+
expect(stream).toBe(sample.stream);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
describe('#stat', () => {
|
|
420
|
+
let mockResponse;
|
|
421
|
+
let mockResponseBody;
|
|
422
|
+
beforeEach(() => {
|
|
423
|
+
mockResponseBody = {
|
|
424
|
+
bytes: sample.file.size,
|
|
425
|
+
created_at: sample.file.modified.toISOString(),
|
|
426
|
+
};
|
|
427
|
+
mockResponse = {
|
|
428
|
+
json: vi.fn().mockResolvedValue(mockResponseBody),
|
|
429
|
+
status: 200,
|
|
430
|
+
};
|
|
431
|
+
vi.mocked(fetch).mockResolvedValue(mockResponse);
|
|
432
|
+
});
|
|
433
|
+
test('Gets full path for given filepath', async () => {
|
|
434
|
+
await driver.stat(sample.path.input);
|
|
435
|
+
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
|
|
436
|
+
});
|
|
437
|
+
test('Gets resource type for given filepath', async () => {
|
|
438
|
+
await driver.stat(sample.path.input);
|
|
439
|
+
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.inputFull);
|
|
440
|
+
});
|
|
441
|
+
test('Gets publicId for given filepath', async () => {
|
|
442
|
+
await driver.stat(sample.path.input);
|
|
443
|
+
expect(driver['getPublicId']).toHaveBeenCalledWith(sample.path.inputFull);
|
|
444
|
+
});
|
|
445
|
+
test('Creates signature for body parameters', async () => {
|
|
446
|
+
await driver.stat(sample.path.input);
|
|
447
|
+
expect(driver['getFullSignature']).toHaveBeenCalledWith({
|
|
448
|
+
type: 'upload',
|
|
449
|
+
public_id: sample.publicId.input,
|
|
450
|
+
api_key: sample.config.apiKey,
|
|
451
|
+
timestamp: sample.timestamp,
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
test('Creates form url encoded body ', async () => {
|
|
455
|
+
await driver.stat(sample.path.input);
|
|
456
|
+
expect(driver['toFormUrlEncoded']).toHaveBeenCalledWith({
|
|
457
|
+
type: 'upload',
|
|
458
|
+
public_id: sample.publicId.input,
|
|
459
|
+
api_key: sample.config.apiKey,
|
|
460
|
+
timestamp: sample.timestamp,
|
|
461
|
+
signature: sample.fullSignature,
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
test('Fetches URL with url encoded body', async () => {
|
|
465
|
+
await driver.stat(sample.path.input);
|
|
466
|
+
expect(fetch).toHaveBeenCalledWith(`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/${sample.resourceType}/explicit`, {
|
|
467
|
+
method: 'POST',
|
|
468
|
+
headers: {
|
|
469
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
470
|
+
},
|
|
471
|
+
body: sample.formUrlEncoded,
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
test('Throws error when status is >400', async () => {
|
|
475
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
476
|
+
try {
|
|
477
|
+
await driver.stat(sample.path.input);
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
expect(err).toBeInstanceOf(Error);
|
|
481
|
+
expect(err.message).toBe(`No stat returned for file "${sample.path.input}"`);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
test('Returns size/modified from bytes/created_at from Cloudinary', async () => {
|
|
485
|
+
const result = await driver.stat(sample.path.input);
|
|
486
|
+
expect(result).toStrictEqual({
|
|
487
|
+
size: sample.file.size,
|
|
488
|
+
modified: sample.file.modified,
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
describe('#exists', () => {
|
|
493
|
+
beforeEach(() => {
|
|
494
|
+
driver['stat'] = vi.fn().mockResolvedValue({ size: sample.file.size, modified: sample.file.modified });
|
|
495
|
+
});
|
|
496
|
+
test('Calls stat', async () => {
|
|
497
|
+
await driver.exists(sample.path.input);
|
|
498
|
+
expect(driver['stat']).toHaveBeenCalledWith(sample.path.input);
|
|
499
|
+
});
|
|
500
|
+
test('Returns true if stat returns the stats', async () => {
|
|
501
|
+
const exists = await driver.exists(sample.path.input);
|
|
502
|
+
expect(exists).toBe(true);
|
|
503
|
+
});
|
|
504
|
+
test('Returns false if stat throws an error', async () => {
|
|
505
|
+
vi.mocked(driver.stat).mockRejectedValue(new Error());
|
|
506
|
+
const exists = await driver.exists(sample.path.input);
|
|
507
|
+
expect(exists).toBe(false);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
describe('#move', () => {
|
|
511
|
+
let mockResponse;
|
|
512
|
+
let mockResponseBody;
|
|
513
|
+
beforeEach(() => {
|
|
514
|
+
mockResponseBody = {};
|
|
515
|
+
mockResponse = {
|
|
516
|
+
json: vi.fn().mockResolvedValue(mockResponseBody),
|
|
517
|
+
status: 200,
|
|
518
|
+
};
|
|
519
|
+
vi.mocked(fetch).mockResolvedValue(mockResponse);
|
|
520
|
+
});
|
|
521
|
+
test('Gets full path for src', async () => {
|
|
522
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
523
|
+
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.src);
|
|
524
|
+
});
|
|
525
|
+
test('Gets full path for dest', async () => {
|
|
526
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
527
|
+
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.dest);
|
|
528
|
+
});
|
|
529
|
+
test('Gets resource type for src', async () => {
|
|
530
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
531
|
+
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.srcFull);
|
|
532
|
+
});
|
|
533
|
+
test('Creates signature for body parameters', async () => {
|
|
534
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
535
|
+
expect(driver['getFullSignature']).toHaveBeenCalledWith({
|
|
536
|
+
from_public_id: sample.publicId.src,
|
|
537
|
+
to_public_id: sample.publicId.dest,
|
|
538
|
+
api_key: sample.config.apiKey,
|
|
539
|
+
timestamp: sample.timestamp,
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
test('Creates form url encoded body ', async () => {
|
|
543
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
544
|
+
expect(driver['toFormUrlEncoded']).toHaveBeenCalledWith({
|
|
545
|
+
from_public_id: sample.publicId.src,
|
|
546
|
+
to_public_id: sample.publicId.dest,
|
|
547
|
+
api_key: sample.config.apiKey,
|
|
548
|
+
timestamp: sample.timestamp,
|
|
549
|
+
signature: sample.fullSignature,
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
test('Fetches URL with url encoded body', async () => {
|
|
553
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
554
|
+
expect(fetch).toHaveBeenCalledWith(`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/${sample.resourceType}/rename`, {
|
|
555
|
+
method: 'POST',
|
|
556
|
+
headers: {
|
|
557
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
558
|
+
},
|
|
559
|
+
body: sample.formUrlEncoded,
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
test('Throws error if status is >400', async () => {
|
|
563
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
564
|
+
try {
|
|
565
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
expect(err).toBeInstanceOf(Error);
|
|
569
|
+
expect(err.message).toBe(`Can't move file "${sample.path.src}": Unknown`);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
test(`Defaults to Unknown if error object doesn't contain message`, async () => {
|
|
573
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
574
|
+
mockResponseBody.error = {};
|
|
575
|
+
try {
|
|
576
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
expect(err).toBeInstanceOf(Error);
|
|
580
|
+
expect(err.message).toBe(`Can't move file "${sample.path.src}": Unknown`);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
test(`Renders message if returned by Cloudinary`, async () => {
|
|
584
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
585
|
+
mockResponseBody.error = { message: randText() };
|
|
586
|
+
try {
|
|
587
|
+
await driver.move(sample.path.src, sample.path.dest);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
expect(err).toBeInstanceOf(Error);
|
|
591
|
+
expect(err.message).toBe(`Can't move file "${sample.path.src}": ${mockResponseBody.error.message}`);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
describe('#copy', () => {
|
|
596
|
+
beforeEach(() => {
|
|
597
|
+
driver.read = vi.fn().mockResolvedValue(sample.stream);
|
|
598
|
+
driver.write = vi.fn();
|
|
599
|
+
});
|
|
600
|
+
test('Calls read with input path', async () => {
|
|
601
|
+
await driver.copy(sample.path.src, sample.path.dest);
|
|
602
|
+
expect(driver.read).toHaveBeenCalledWith(sample.path.src);
|
|
603
|
+
});
|
|
604
|
+
test('Calls write with dest path and read stream', async () => {
|
|
605
|
+
await driver.copy(sample.path.src, sample.path.dest);
|
|
606
|
+
expect(driver.write).toHaveBeenCalledWith(sample.path.dest, sample.stream);
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
describe('#write', () => {
|
|
610
|
+
let stream;
|
|
611
|
+
let chunks;
|
|
612
|
+
beforeEach(async () => {
|
|
613
|
+
chunks = randUnique({ length: randNumber({ min: 1, max: 10 }) });
|
|
614
|
+
async function* generate() {
|
|
615
|
+
for (const chunk of chunks) {
|
|
616
|
+
yield chunk;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
stream = Readable.from(generate());
|
|
620
|
+
driver['uploadChunk'] = vi.fn();
|
|
621
|
+
});
|
|
622
|
+
test('Gets full path for input', async () => {
|
|
623
|
+
await driver.write(sample.path.input, stream);
|
|
624
|
+
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
|
|
625
|
+
});
|
|
626
|
+
test('Gets resource type for full path', async () => {
|
|
627
|
+
await driver.write(sample.path.input, stream);
|
|
628
|
+
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.inputFull);
|
|
629
|
+
});
|
|
630
|
+
test('Constructs signature from correct upload parameters', async () => {
|
|
631
|
+
await driver.write(sample.path.input, stream);
|
|
632
|
+
expect(driver['getFullSignature']).toHaveBeenCalledWith({
|
|
633
|
+
timestamp: sample.timestamp,
|
|
634
|
+
api_key: sample.config.apiKey,
|
|
635
|
+
type: 'upload',
|
|
636
|
+
access_mode: sample.config.accessMode,
|
|
637
|
+
public_id: sample.publicId.input,
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
test('Queues upload once stream has buffered', async () => {
|
|
641
|
+
await driver.write(sample.path.input, stream);
|
|
642
|
+
expect(driver['uploadChunk']).toHaveBeenCalledOnce();
|
|
643
|
+
expect(driver['uploadChunk']).toHaveBeenCalledWith({
|
|
644
|
+
resourceType: sample.resourceType,
|
|
645
|
+
blob: new Blob(chunks),
|
|
646
|
+
bytesOffset: 0,
|
|
647
|
+
bytesTotal: new Blob(chunks).size,
|
|
648
|
+
parameters: {
|
|
649
|
+
timestamp: sample.timestamp,
|
|
650
|
+
access_mode: sample.config.accessMode,
|
|
651
|
+
api_key: sample.config.apiKey,
|
|
652
|
+
public_id: sample.publicId.input,
|
|
653
|
+
signature: sample.fullSignature,
|
|
654
|
+
type: 'upload',
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
test('Queues chunk upload each time chunks add up to at least 5.5MB of data', async () => {
|
|
659
|
+
const chunk1 = Buffer.alloc(3e6).toString(); // nothing happens yet
|
|
660
|
+
const chunk2 = Buffer.alloc(3e6).toString(); // adds up to 6MB together with chunk1, so sent as chunk
|
|
661
|
+
const chunk3 = Buffer.alloc(1e6).toString(); // sent as separate final request
|
|
662
|
+
chunks = [chunk1, chunk2, chunk3];
|
|
663
|
+
await driver.write(sample.path.input, stream);
|
|
664
|
+
expect(driver['uploadChunk']).toHaveBeenCalledTimes(2);
|
|
665
|
+
expect(driver['uploadChunk']).toHaveBeenCalledWith({
|
|
666
|
+
resourceType: sample.resourceType,
|
|
667
|
+
blob: new Blob([chunk1, chunk2]),
|
|
668
|
+
bytesOffset: 0,
|
|
669
|
+
bytesTotal: -1,
|
|
670
|
+
parameters: {
|
|
671
|
+
timestamp: sample.timestamp,
|
|
672
|
+
access_mode: sample.config.accessMode,
|
|
673
|
+
api_key: sample.config.apiKey,
|
|
674
|
+
public_id: sample.publicId.input,
|
|
675
|
+
signature: sample.fullSignature,
|
|
676
|
+
type: 'upload',
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
test('Sends final chunk with total size information', async () => {
|
|
681
|
+
const chunk1 = Buffer.alloc(3e6).toString(); // nothing happens yet
|
|
682
|
+
const chunk2 = Buffer.alloc(3e6).toString(); // adds up to 6MB together with chunk1, so sent as chunk
|
|
683
|
+
const chunk3 = Buffer.alloc(1e6).toString(); // sent as separate final request
|
|
684
|
+
chunks = [chunk1, chunk2, chunk3];
|
|
685
|
+
await driver.write(sample.path.input, stream);
|
|
686
|
+
expect(driver['uploadChunk']).toHaveBeenCalledTimes(2);
|
|
687
|
+
expect(driver['uploadChunk']).toHaveBeenCalledWith({
|
|
688
|
+
resourceType: sample.resourceType,
|
|
689
|
+
blob: new Blob([chunk3]),
|
|
690
|
+
bytesOffset: 6e6,
|
|
691
|
+
bytesTotal: 7e6,
|
|
692
|
+
parameters: {
|
|
693
|
+
timestamp: sample.timestamp,
|
|
694
|
+
access_mode: sample.config.accessMode,
|
|
695
|
+
api_key: sample.config.apiKey,
|
|
696
|
+
public_id: sample.publicId.input,
|
|
697
|
+
signature: sample.fullSignature,
|
|
698
|
+
type: 'upload',
|
|
699
|
+
},
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
test('Throws error if one of the chunks failed to upload', async () => {
|
|
703
|
+
const chunk1 = Buffer.alloc(3e6).toString(); // nothing happens yet
|
|
704
|
+
const chunk2 = Buffer.alloc(3e6).toString(); // adds up to 6MB together with chunk1, so sent as chunk
|
|
705
|
+
const chunk3 = Buffer.alloc(1e6).toString(); // sent as separate final request
|
|
706
|
+
chunks = [chunk1, chunk2, chunk3];
|
|
707
|
+
const mockErrorMessage = randText();
|
|
708
|
+
vi.mocked(driver['uploadChunk']).mockRejectedValueOnce(new Error(mockErrorMessage));
|
|
709
|
+
vi.mocked(driver['uploadChunk']).mockResolvedValueOnce();
|
|
710
|
+
try {
|
|
711
|
+
await driver.write(sample.path.input, stream);
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
expect(err).toBeInstanceOf(Error);
|
|
715
|
+
expect(err.message).toBe(`Can't upload file "${sample.path.input}": ${mockErrorMessage}`);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
test('Throws error if last chunk failed to upload', async () => {
|
|
719
|
+
const chunk1 = Buffer.alloc(3e6).toString(); // nothing happens yet
|
|
720
|
+
const chunk2 = Buffer.alloc(3e6).toString(); // adds up to 6MB together with chunk1, so sent as chunk
|
|
721
|
+
const chunk3 = Buffer.alloc(1e6).toString(); // sent as separate final request
|
|
722
|
+
chunks = [chunk1, chunk2, chunk3];
|
|
723
|
+
const mockErrorMessage = randText();
|
|
724
|
+
vi.mocked(driver['uploadChunk']).mockResolvedValueOnce();
|
|
725
|
+
vi.mocked(driver['uploadChunk']).mockRejectedValueOnce(new Error(mockErrorMessage));
|
|
726
|
+
try {
|
|
727
|
+
await driver.write(sample.path.input, stream);
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
expect(err).toBeInstanceOf(Error);
|
|
731
|
+
expect(err.message).toBe(`Can't upload file "${sample.path.input}": ${mockErrorMessage}`);
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
describe('#uploadChunk', () => {
|
|
736
|
+
let mockResponse;
|
|
737
|
+
let mockResponseBody;
|
|
738
|
+
let mockFormData;
|
|
739
|
+
let input;
|
|
740
|
+
beforeEach(() => {
|
|
741
|
+
mockResponseBody = {};
|
|
742
|
+
mockResponse = {
|
|
743
|
+
json: vi.fn().mockResolvedValue(mockResponseBody),
|
|
744
|
+
status: 200,
|
|
745
|
+
};
|
|
746
|
+
mockFormData = {
|
|
747
|
+
set: vi.fn(),
|
|
748
|
+
};
|
|
749
|
+
input = {
|
|
750
|
+
resourceType: sample.resourceType,
|
|
751
|
+
blob: { size: randNumber({ min: 0, max: 1500 }) },
|
|
752
|
+
bytesOffset: randNumber({ min: 0, max: 500 }),
|
|
753
|
+
bytesTotal: randNumber(),
|
|
754
|
+
parameters: {
|
|
755
|
+
timestamp: sample.timestamp,
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
vi.mocked(fetch).mockResolvedValue(mockResponse);
|
|
759
|
+
vi.mocked(FormData).mockReturnValue(mockFormData);
|
|
760
|
+
});
|
|
761
|
+
test('Creates FormData object', async () => {
|
|
762
|
+
await driver['uploadChunk'](input);
|
|
763
|
+
expect(FormData).toHaveBeenCalledOnce();
|
|
764
|
+
expect(FormData).toHaveBeenCalledWith();
|
|
765
|
+
});
|
|
766
|
+
test('Saves all passed parameters to form data', async () => {
|
|
767
|
+
const keys = randUnique({ length: randNumber({ min: 1, max: 10 }) });
|
|
768
|
+
const values = randUnique({ length: randNumber({ min: 1, max: 10 }) });
|
|
769
|
+
keys.forEach((key, index) => {
|
|
770
|
+
input.parameters[key] = values[index];
|
|
771
|
+
});
|
|
772
|
+
await driver['uploadChunk'](input);
|
|
773
|
+
keys.forEach((key, index) => {
|
|
774
|
+
expect(mockFormData.set).toHaveBeenCalledWith(key, values[index]);
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
test('Calls fetch with formData an range header', async () => {
|
|
778
|
+
await driver['uploadChunk'](input);
|
|
779
|
+
expect(fetch).toHaveBeenCalledWith(`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/${sample.resourceType}/upload`, {
|
|
780
|
+
method: 'POST',
|
|
781
|
+
body: mockFormData,
|
|
782
|
+
headers: {
|
|
783
|
+
'X-Unique-Upload-Id': sample.timestamp,
|
|
784
|
+
'Content-Range': `bytes ${input.bytesOffset}-${input.bytesOffset + input.blob.size - 1}/${input.bytesTotal}`,
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
test('Throws an error when the response statusCode is >=400', async () => {
|
|
789
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
790
|
+
try {
|
|
791
|
+
await driver['uploadChunk'](input);
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
expect(err).toBeInstanceOf(Error);
|
|
795
|
+
expect(err.message).toBe('Unknown');
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
test('Defaults to Unknown if Cloudinary API response error message is not known', async () => {
|
|
799
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
800
|
+
mockResponseBody.error = {};
|
|
801
|
+
try {
|
|
802
|
+
await driver['uploadChunk'](input);
|
|
803
|
+
}
|
|
804
|
+
catch (err) {
|
|
805
|
+
expect(err).toBeInstanceOf(Error);
|
|
806
|
+
expect(err.message).toBe('Unknown');
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
test('Sets error message to Cloudinary return message', async () => {
|
|
810
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
811
|
+
mockResponseBody.error = { message: randWord() };
|
|
812
|
+
try {
|
|
813
|
+
await driver['uploadChunk'](input);
|
|
814
|
+
}
|
|
815
|
+
catch (err) {
|
|
816
|
+
expect(err).toBeInstanceOf(Error);
|
|
817
|
+
expect(err.message).toBe(mockResponseBody.error.message);
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
describe('#delete', () => {
|
|
822
|
+
beforeEach(async () => {
|
|
823
|
+
await driver.delete(sample.path.input);
|
|
824
|
+
});
|
|
825
|
+
test('Gets full path', () => {
|
|
826
|
+
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
|
|
827
|
+
});
|
|
828
|
+
test('Gets resource type for full path', () => {
|
|
829
|
+
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.inputFull);
|
|
830
|
+
});
|
|
831
|
+
test('Gets publicId for full path', () => {
|
|
832
|
+
expect(driver['getPublicId']).toHaveBeenCalledWith(sample.path.inputFull);
|
|
833
|
+
});
|
|
834
|
+
test('Generates signature for delete parameters', () => {
|
|
835
|
+
expect(driver['getFullSignature']).toHaveBeenCalledWith({
|
|
836
|
+
timestamp: sample.timestamp,
|
|
837
|
+
api_key: sample.config.apiKey,
|
|
838
|
+
resource_type: sample.resourceType,
|
|
839
|
+
public_id: sample.publicId.input,
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
test('Calls fetch with correct parameters', () => {
|
|
843
|
+
expect(driver['toFormUrlEncoded']).toHaveBeenCalledWith({
|
|
844
|
+
timestamp: sample.timestamp,
|
|
845
|
+
api_key: sample.config.apiKey,
|
|
846
|
+
resource_type: sample.resourceType,
|
|
847
|
+
public_id: sample.publicId.input,
|
|
848
|
+
signature: sample.fullSignature,
|
|
849
|
+
});
|
|
850
|
+
expect(fetch).toHaveBeenCalledWith(`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/${sample.resourceType}/destroy`, {
|
|
851
|
+
method: 'POST',
|
|
852
|
+
body: sample.formUrlEncoded,
|
|
853
|
+
headers: {
|
|
854
|
+
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
describe('#list', () => {
|
|
860
|
+
let mockResponse;
|
|
861
|
+
let mockFilePaths;
|
|
862
|
+
let mockResponseBody;
|
|
863
|
+
beforeEach(() => {
|
|
864
|
+
mockFilePaths = randFilePath({ length: randNumber({ min: 1, max: 10 }) });
|
|
865
|
+
mockResponseBody = {
|
|
866
|
+
resources: mockFilePaths.map((filepath) => ({
|
|
867
|
+
public_id: filepath,
|
|
868
|
+
})),
|
|
869
|
+
};
|
|
870
|
+
mockResponse = {
|
|
871
|
+
status: 200,
|
|
872
|
+
json: vi.fn().mockResolvedValue(mockResponseBody),
|
|
873
|
+
};
|
|
874
|
+
vi.mocked(fetch).mockResolvedValue(mockResponse);
|
|
875
|
+
});
|
|
876
|
+
test('Gets full path for prefix', async () => {
|
|
877
|
+
await driver.list(sample.path.input).next();
|
|
878
|
+
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
|
|
879
|
+
});
|
|
880
|
+
test('Gets public id for prefix', async () => {
|
|
881
|
+
await driver.list(sample.path.input).next();
|
|
882
|
+
expect(driver['getPublicId']).toHaveBeenCalledWith(sample.path.inputFull);
|
|
883
|
+
});
|
|
884
|
+
test('Fetches search api results', async () => {
|
|
885
|
+
await driver.list(sample.path.input).next();
|
|
886
|
+
expect(fetch).toHaveBeenCalledWith(`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/resources/search?expression=${sample.publicId.input}*&next_cursor=`, {
|
|
887
|
+
method: 'GET',
|
|
888
|
+
headers: {
|
|
889
|
+
Authorization: sample.basicAuth,
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
test('Yields resource public IDs from response', async () => {
|
|
894
|
+
const output = [];
|
|
895
|
+
for await (const path of driver.list(sample.path.input)) {
|
|
896
|
+
output.push(path);
|
|
897
|
+
}
|
|
898
|
+
expect(output.length).toBe(mockResponseBody.resources.length);
|
|
899
|
+
expect(output).toStrictEqual(mockFilePaths);
|
|
900
|
+
});
|
|
901
|
+
test('Keeps calling fetch as long as a next_cursor is returned', async () => {
|
|
902
|
+
const mockNextCursor = randSha();
|
|
903
|
+
mockResponse.json.mockResolvedValueOnce({
|
|
904
|
+
...mockResponseBody,
|
|
905
|
+
next_cursor: mockNextCursor,
|
|
906
|
+
});
|
|
907
|
+
const output = [];
|
|
908
|
+
for await (const path of driver.list(sample.path.input)) {
|
|
909
|
+
output.push(path);
|
|
910
|
+
}
|
|
911
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
912
|
+
expect(fetch).toHaveBeenCalledWith(`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/resources/search?expression=${sample.publicId.input}*&next_cursor=${mockNextCursor}`, {
|
|
913
|
+
method: 'GET',
|
|
914
|
+
headers: {
|
|
915
|
+
Authorization: sample.basicAuth,
|
|
916
|
+
},
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
test('Throws error if search api fails', async () => {
|
|
920
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
921
|
+
try {
|
|
922
|
+
await driver.list(sample.path.input).next();
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
expect(err).toBeInstanceOf(Error);
|
|
926
|
+
expect(err.message).toBe(`Can't list for prefix "${sample.path.input}": Unknown`);
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
test('Defaults to Unknown error if the error response does not contain a message', async () => {
|
|
930
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
931
|
+
mockResponseBody.error = {};
|
|
932
|
+
try {
|
|
933
|
+
await driver.list(sample.path.input).next();
|
|
934
|
+
}
|
|
935
|
+
catch (err) {
|
|
936
|
+
expect(err).toBeInstanceOf(Error);
|
|
937
|
+
expect(err.message).toBe(`Can't list for prefix "${sample.path.input}": Unknown`);
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
test('Provides Cloudinary error message', async () => {
|
|
941
|
+
mockResponse.status = randNumber({ min: 400, max: 599 });
|
|
942
|
+
mockResponseBody.error = { message: randText() };
|
|
943
|
+
try {
|
|
944
|
+
await driver.list(sample.path.input).next();
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
expect(err).toBeInstanceOf(Error);
|
|
948
|
+
expect(err.message).toBe(`Can't list for prefix "${sample.path.input}": ${mockResponseBody.error.message}`);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wbce-d9/storage-driver-cloudinary",
|
|
3
|
+
"version": "9.26.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Cloudinary file storage abstraction for `@wbce-d9/storage`",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/LaWebcapsule/directus9.git",
|
|
9
|
+
"directory": "packages/storage-driver-cloudinary"
|
|
10
|
+
},
|
|
11
|
+
"funding": "https://github.com/LaWebcapsule/directus9?sponsor=1",
|
|
12
|
+
"license": "GPL-3.0",
|
|
13
|
+
"author": "Rijk van Zanten <rijkvanzanten@me.com>",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./dist/index.js",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"main": "dist/index.js",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"!**/*.d.ts?(.map)"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"p-queue": "7.3.4",
|
|
28
|
+
"undici": "5.22.0",
|
|
29
|
+
"@wbce-d9/storage": "9.26.3",
|
|
30
|
+
"@wbce-d9/utils": "9.26.3"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@wbce-d9/tsconfig": "0.0.7",
|
|
34
|
+
"@ngneat/falso": "6.4.0",
|
|
35
|
+
"@vitest/coverage-c8": "0.30.1",
|
|
36
|
+
"typescript": "5.0.4",
|
|
37
|
+
"vitest": "0.30.1"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc --build",
|
|
41
|
+
"dev": "tsc --watch",
|
|
42
|
+
"test": "vitest --watch=false"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/readme.md
ADDED