Files
directus/packages/storage-driver-cloudinary/src/index.test.ts
Rijk van Zanten c48309ab68 Last eslint tweak (#18198)
* Should be there now

* Format
2023-04-14 17:40:50 -04:00

1288 lines
36 KiB
TypeScript

import { normalizePath } from '@directus/utils';
import {
rand,
randAlphaNumeric,
randDirectoryPath,
randFilePath,
randFileType,
randGitBranch as randCloudName,
randGitCommitSha as randSha,
randGitShortSha as randUnique,
randNumber,
randPastDate,
randText,
randWord,
} from '@ngneat/falso';
import { Blob } from 'node:buffer';
import type { Hash } from 'node:crypto';
import { createHash } from 'node:crypto';
import type { ParsedPath } from 'node:path';
import { extname, join, parse } from 'node:path';
import { PassThrough, Readable } from 'node:stream';
import { ReadableStream } from 'node:stream/web';
import type { Response } from 'undici';
import { fetch, FormData } from 'undici';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { IMAGE_EXTENSIONS, VIDEO_EXTENSIONS } from './constants.js';
import type { DriverCloudinaryConfig } from './index.js';
import { DriverCloudinary } from './index.js';
vi.mock('@directus/utils/node');
vi.mock('@directus/utils');
vi.mock('node:path');
vi.mock('node:crypto');
vi.mock('undici');
let sample: {
config: Required<DriverCloudinaryConfig>;
path: {
input: string;
inputFull: string;
src: string;
srcFull: string;
dest: string;
destFull: string;
};
range: {
start: number;
end: number;
};
stream: PassThrough;
text: string;
file: {
type: string;
size: number;
modified: Date;
};
resourceType: 'image' | 'video' | 'raw';
publicId: {
input: string;
src: string;
dest: string;
};
parameterSignature: string;
fullSignature: string;
basicAuth: string;
timestamp: string;
formUrlEncoded: string;
};
let driver: DriverCloudinary;
beforeEach(() => {
sample = {
config: {
root: randDirectoryPath(),
apiKey: randNumber({ length: 15 }).join(''),
apiSecret: randAlphaNumeric({ length: 27 }).join(''),
cloudName: randCloudName(),
accessMode: rand(['public', 'authenticated']),
},
path: {
input: randUnique() + randFilePath(),
inputFull: randUnique() + randFilePath(),
src: randUnique() + randFilePath(),
srcFull: randUnique() + randFilePath(),
dest: randUnique() + randFilePath(),
destFull: randUnique() + randFilePath(),
},
range: {
start: randNumber(),
end: randNumber(),
},
stream: new PassThrough(),
text: randText(),
file: {
type: randFileType(),
size: randNumber(),
modified: randPastDate(),
},
resourceType: rand(['image', 'video', 'raw']),
publicId: {
input: randUnique() + randFilePath(),
src: randUnique() + randFilePath(),
dest: randUnique() + randFilePath(),
},
parameterSignature: `s--${randAlphaNumeric({ length: 8 }).join('')}--`,
fullSignature: randSha(),
basicAuth: `Basic ${randSha()}`,
timestamp: String(randPastDate().getTime()),
formUrlEncoded: randAlphaNumeric({ length: 30 }).join(''),
};
driver = new DriverCloudinary({
cloudName: sample.config.cloudName,
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
accessMode: sample.config.accessMode,
});
driver['fullPath'] = vi.fn().mockImplementation((input) => {
if (input === sample.path.src) return sample.path.srcFull;
if (input === sample.path.dest) return sample.path.destFull;
if (input === sample.path.input) return sample.path.inputFull;
return '';
});
driver['getPublicId'] = vi.fn().mockImplementation((input) => {
if (input === sample.path.srcFull) return sample.publicId.src;
if (input === sample.path.destFull) return sample.publicId.dest;
if (input === sample.path.inputFull) return sample.publicId.input;
return '';
});
driver['getResourceType'] = vi.fn().mockReturnValue(sample.resourceType);
driver['getParameterSignature'] = vi.fn().mockReturnValue(sample.parameterSignature);
driver['getBasicAuth'] = vi.fn().mockReturnValue(sample.basicAuth);
driver['getFullSignature'] = vi.fn().mockReturnValue(sample.fullSignature);
driver['getTimestamp'] = vi.fn().mockReturnValue(sample.timestamp);
driver['toFormUrlEncoded'] = vi.fn().mockReturnValue(sample.formUrlEncoded);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('#constructor', () => {
test('Saves apiKey internally', () => {
expect(driver['apiKey']).toBe(sample.config.apiKey);
});
test('Saves apiSecret internally', () => {
expect(driver['apiSecret']).toBe(sample.config.apiSecret);
});
test('Saves cloudName internally', () => {
expect(driver['cloudName']).toBe(sample.config.cloudName);
});
test('Saves accessMode internally', () => {
expect(driver['accessMode']).toBe(sample.config.accessMode);
});
test('Defaults root to empty string', () => {
expect(driver['root']).toBe('');
});
test('Normalizes config path when root is given', () => {
vi.mocked(normalizePath).mockReturnValue(sample.path.inputFull);
new DriverCloudinary({
cloudName: sample.config.cloudName,
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
root: sample.config.root,
accessMode: sample.config.accessMode,
});
expect(normalizePath).toHaveBeenCalledWith(sample.config.root, { removeLeading: true });
});
});
describe('#fullPath', () => {
test('Returns normalized joined path', () => {
vi.mocked(join).mockReturnValue(sample.path.inputFull);
vi.mocked(normalizePath).mockReturnValue(sample.path.inputFull);
const driver = new DriverCloudinary({
cloudName: sample.config.cloudName,
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
accessMode: sample.config.accessMode,
});
driver['root'] = sample.config.root;
const result = driver['fullPath'](sample.path.input);
expect(join).toHaveBeenCalledWith(sample.config.root, sample.path.input);
expect(normalizePath).toHaveBeenCalledWith(sample.path.inputFull, { removeLeading: true });
expect(result).toBe(sample.path.inputFull);
});
});
describe('#toFormUrlEncoded', () => {
let mockProps: [string, string, string];
let mockValues: [string, string, string];
beforeEach(() => {
driver = new DriverCloudinary({
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
cloudName: sample.config.cloudName,
accessMode: sample.config.accessMode,
});
mockProps = Array.from(Array(3), () => randAlphaNumeric({ length: randNumber({ min: 2, max: 15 }) }).join('')) as [
string,
string,
string
];
mockValues = randWord({ length: 3 }) as [string, string, string];
});
test('Parses plain object of strings', () => {
const result = driver['toFormUrlEncoded']({
[mockProps[0]]: mockValues[0],
[mockProps[1]]: mockValues[1],
[mockProps[2]]: mockValues[2],
});
// The order isn't guaranteed
expect(result).toContain(`${mockProps[0]}=${mockValues[0]}`);
expect(result).toContain(`${mockProps[1]}=${mockValues[1]}`);
expect(result).toContain(`${mockProps[2]}=${mockValues[2]}`);
});
test('Optionally sorts the properties alphabetically', () => {
// Expected order should be 2-0-1
mockProps[0] = `b_${mockProps[0]}`;
mockProps[1] = `c_${mockProps[1]}`;
mockProps[2] = `a_${mockProps[2]}`;
expect(
driver['toFormUrlEncoded'](
{
[mockProps[0]]: mockValues[0],
[mockProps[1]]: mockValues[1],
[mockProps[2]]: mockValues[2],
},
{ sort: true }
)
).toBe(`${mockProps[2]}=${mockValues[2]}&${mockProps[0]}=${mockValues[0]}&${mockProps[1]}=${mockValues[1]}`);
});
});
describe('#getFullSignature', () => {
let mockPayload: Record<string, string>;
let mockCreateHash: {
update: Mock;
digest: Mock;
};
beforeEach(() => {
driver = new DriverCloudinary({
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
cloudName: sample.config.cloudName,
accessMode: sample.config.accessMode,
});
mockCreateHash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn().mockReturnThis(),
};
vi.mocked(createHash).mockReturnValue(mockCreateHash as unknown as Hash);
driver['toFormUrlEncoded'] = vi.fn();
const randLength = randNumber({ min: 1, max: 10 });
const props = randWord({ length: randLength });
const values = randWord({ length: randLength });
mockPayload = Object.fromEntries(props.map((key, index) => [key, values[index]!]));
});
test('Ignores Cloudinary denylist of keys', () => {
const payload = {
...mockPayload,
// Ignored properties:
file: randText(),
cloud_name: randCloudName(),
resource_type: randWord(),
api_key: randAlphaNumeric({ length: 15 }).join(''),
};
driver['getFullSignature'](payload);
expect(driver['toFormUrlEncoded']).toHaveBeenCalledWith(mockPayload, { sort: true });
});
test('Creates sha256 hash', () => {
driver['getFullSignature'](mockPayload);
expect(createHash).toHaveBeenCalledWith('sha256');
});
test('Updates sha256 hash with signature payload + api secret', () => {
const mockFormUrlEncoded = randWord();
vi.mocked(driver['toFormUrlEncoded']).mockReturnValue(mockFormUrlEncoded);
driver['getFullSignature'](mockPayload);
expect(mockCreateHash.update).toHaveBeenCalledWith(mockFormUrlEncoded + sample.config.apiSecret);
});
test('Digests hash as hex', () => {
driver['getFullSignature'](mockPayload);
expect(mockCreateHash.digest).toHaveBeenCalledWith('hex');
});
test('Returns digested hash', () => {
const mockHash = randSha();
mockCreateHash.digest.mockReturnValue(mockHash);
const hash = driver['getFullSignature'](mockPayload);
expect(hash).toBe(mockHash);
});
});
describe('#getParameterSignature', () => {
let mockHash: string;
let result: string;
let mockCreateHash: {
update: Mock;
digest: Mock;
};
beforeEach(() => {
driver = new DriverCloudinary({
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
cloudName: sample.config.cloudName,
accessMode: sample.config.accessMode,
});
mockHash = randSha();
mockCreateHash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn().mockReturnValue(mockHash),
};
vi.mocked(createHash).mockReturnValue(mockCreateHash as unknown as Hash);
result = driver['getParameterSignature'](sample.path.input);
});
test('Creates sha256 hash', () => {
expect(createHash).toHaveBeenCalledWith('sha256');
});
test('Updates hash with passed filepath + apiSecret', () => {
expect(mockCreateHash.update).toHaveBeenCalledWith(sample.path.input + sample.config.apiSecret);
});
test('Digests hash to base64url', () => {
expect(mockCreateHash.digest).toHaveBeenCalledWith('base64url');
});
test('Returns first 8 characters of base64 sha hash wrapped in Cloudinary prefix/suffix', () => {
expect(result).toBe(`s--${mockHash.substring(0, 8)}--`);
});
});
describe('#getTimestamp', () => {
let mockDate: Date;
beforeEach(() => {
driver = new DriverCloudinary({
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
cloudName: sample.config.cloudName,
accessMode: sample.config.accessMode,
});
mockDate = randPastDate();
vi.useFakeTimers();
vi.setSystemTime(mockDate);
});
test('Returns unix timestamp for current time', () => {
expect(driver['getTimestamp']()).toBe(String(mockDate.getTime()));
});
});
describe('#getResourceType', () => {
beforeEach(() => {
driver = new DriverCloudinary({
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
cloudName: sample.config.cloudName,
accessMode: sample.config.accessMode,
});
});
test('Returns "image" for extensions contained in the image extensions constant', () => {
IMAGE_EXTENSIONS.forEach((ext) => {
vi.mocked(extname).mockReturnValue(ext);
const result = driver['getResourceType'](sample.path.inputFull);
expect(extname).toHaveBeenCalledWith(sample.path.inputFull);
expect(result).toBe('image');
});
});
test('Returns "video" for extensions contained in the video extensions constant', () => {
VIDEO_EXTENSIONS.forEach((ext) => {
vi.mocked(extname).mockReturnValue(ext);
const result = driver['getResourceType'](sample.path.inputFull);
expect(extname).toHaveBeenCalledWith(sample.path.inputFull);
expect(result).toBe('video');
});
});
test('Returns "raw" for unknown / other extensions', () => {
randWord({ length: 5 }).forEach((filepath) => expect(driver['getResourceType'](filepath)).toBe('raw'));
});
});
describe('#getPublicId', () => {
let mockParsedPath: string;
beforeEach(() => {
driver = new DriverCloudinary({
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
cloudName: sample.config.cloudName,
accessMode: sample.config.accessMode,
});
driver['getResourceType'] = vi.fn().mockReturnValue(sample.resourceType);
mockParsedPath = randDirectoryPath();
vi.mocked(parse).mockReturnValue({ name: mockParsedPath } as ParsedPath);
});
test('Gets resourceType for given filepath', () => {
driver['getPublicId'](sample.path.input);
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.input);
});
test('Returns original file path if type is raw', () => {
driver['getResourceType'] = vi.fn().mockReturnValue('raw');
const publicId = driver['getPublicId'](sample.path.input);
expect(publicId).toBe(sample.path.input);
});
test('Parsed base path if other type', () => {
driver['getResourceType'] = vi.fn().mockReturnValue(rand(['image', 'video']));
const publicId = driver['getPublicId'](sample.path.input);
expect(publicId).toBe(mockParsedPath);
});
});
describe('#getBasicAuth', () => {
let mockToString: Mock;
beforeEach(() => {
driver = new DriverCloudinary({
apiKey: sample.config.apiKey,
apiSecret: sample.config.apiSecret,
cloudName: sample.config.cloudName,
accessMode: sample.config.accessMode,
});
mockToString = vi.fn();
vi.spyOn(Buffer, 'from').mockReturnValue({ toString: mockToString } as unknown as Buffer);
});
test('Creates base64 hash of key:secret', () => {
driver['getBasicAuth']();
expect(Buffer.from).toHaveBeenCalledWith(`${sample.config.apiKey}:${sample.config.apiSecret}`);
expect(mockToString).toHaveBeenCalledWith('base64');
});
test(`Returns 'Basic <base64>'`, () => {
const mockBase64 = randSha();
mockToString.mockReturnValue(mockBase64);
const result = driver['getBasicAuth']();
expect(result).toBe(`Basic ${mockBase64}`);
});
});
describe('#read', () => {
let mockResponse: {
status: number;
body: ReadableStream | null;
};
beforeEach(() => {
mockResponse = {
status: 200,
body: new ReadableStream(),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
vi.spyOn(Readable, 'fromWeb').mockReturnValue(sample.stream);
});
test('Gets resource type for extension of given filepath', async () => {
await driver.read(sample.path.input);
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.input);
});
test('Creates signature for full filepath', async () => {
await driver.read(sample.path.input);
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
expect(driver['getParameterSignature']).toHaveBeenCalledWith(sample.path.inputFull);
});
test('Calls fetch with generated URL', async () => {
await driver.read(sample.path.input);
expect(fetch).toHaveBeenCalledWith(
`https://res.cloudinary.com/${sample.config.cloudName}/${sample.resourceType}/upload/${sample.parameterSignature}/${sample.path.inputFull}`,
{ method: 'GET' }
);
});
test('Adds optional Range header for start', async () => {
await driver.read(sample.path.input, { start: sample.range.start });
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}-` } }
);
});
test('Adds optional Range header for end', async () => {
await driver.read(sample.path.input, { end: sample.range.end });
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}` } }
);
});
test('Adds optional Range header for start and end', async () => {
await driver.read(sample.path.input, sample.range);
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}` } }
);
});
test('Throws error when response has status >= 400', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
try {
await driver.read(sample.path.input);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`No stream returned for file "${sample.path.input}"`);
}
});
test('Throws error when response has no readable body', async () => {
mockResponse.body = null;
try {
await driver.read(sample.path.input);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`No stream returned for file "${sample.path.input}"`);
}
});
test('Returns readable stream from web stream', async () => {
const stream = await driver.read(sample.path.input);
expect(Readable.fromWeb).toHaveBeenCalledWith(mockResponse.body);
expect(stream).toBe(sample.stream);
});
});
describe('#stat', () => {
let mockResponse: { json: Mock; status: number };
let mockResponseBody: {
bytes: number;
created_at: string;
};
beforeEach(() => {
mockResponseBody = {
bytes: sample.file.size,
created_at: sample.file.modified.toISOString(),
};
mockResponse = {
json: vi.fn().mockResolvedValue(mockResponseBody),
status: 200,
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
});
test('Gets full path for given filepath', async () => {
await driver.stat(sample.path.input);
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
});
test('Gets resource type for given filepath', async () => {
await driver.stat(sample.path.input);
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.inputFull);
});
test('Gets publicId for given filepath', async () => {
await driver.stat(sample.path.input);
expect(driver['getPublicId']).toHaveBeenCalledWith(sample.path.inputFull);
});
test('Creates signature for body parameters', async () => {
await driver.stat(sample.path.input);
expect(driver['getFullSignature']).toHaveBeenCalledWith({
type: 'upload',
public_id: sample.publicId.input,
api_key: sample.config.apiKey,
timestamp: sample.timestamp,
});
});
test('Creates form url encoded body ', async () => {
await driver.stat(sample.path.input);
expect(driver['toFormUrlEncoded']).toHaveBeenCalledWith({
type: 'upload',
public_id: sample.publicId.input,
api_key: sample.config.apiKey,
timestamp: sample.timestamp,
signature: sample.fullSignature,
});
});
test('Fetches URL with url encoded body', async () => {
await driver.stat(sample.path.input);
expect(fetch).toHaveBeenCalledWith(
`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/${sample.resourceType}/explicit`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: sample.formUrlEncoded,
}
);
});
test('Throws error when status is >400', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
try {
await driver.stat(sample.path.input);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`No stat returned for file "${sample.path.input}"`);
}
});
test('Returns size/modified from bytes/created_at from Cloudinary', async () => {
const result = await driver.stat(sample.path.input);
expect(result).toStrictEqual({
size: sample.file.size,
modified: sample.file.modified,
});
});
});
describe('#exists', () => {
beforeEach(() => {
driver['stat'] = vi.fn().mockResolvedValue({ size: sample.file.size, modified: sample.file.modified });
});
test('Calls stat', async () => {
await driver.exists(sample.path.input);
expect(driver['stat']).toHaveBeenCalledWith(sample.path.input);
});
test('Returns true if stat returns the stats', async () => {
const exists = await driver.exists(sample.path.input);
expect(exists).toBe(true);
});
test('Returns false if stat throws an error', async () => {
vi.mocked(driver.stat).mockRejectedValue(new Error());
const exists = await driver.exists(sample.path.input);
expect(exists).toBe(false);
});
});
describe('#move', () => {
let mockResponse: { json: Mock; status: number };
let mockResponseBody: {
error?: { message?: string };
};
beforeEach(() => {
mockResponseBody = {};
mockResponse = {
json: vi.fn().mockResolvedValue(mockResponseBody),
status: 200,
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
});
test('Gets full path for src', async () => {
await driver.move(sample.path.src, sample.path.dest);
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.src);
});
test('Gets full path for dest', async () => {
await driver.move(sample.path.src, sample.path.dest);
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.dest);
});
test('Gets resource type for src', async () => {
await driver.move(sample.path.src, sample.path.dest);
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.srcFull);
});
test('Creates signature for body parameters', async () => {
await driver.move(sample.path.src, sample.path.dest);
expect(driver['getFullSignature']).toHaveBeenCalledWith({
from_public_id: sample.publicId.src,
to_public_id: sample.publicId.dest,
api_key: sample.config.apiKey,
timestamp: sample.timestamp,
});
});
test('Creates form url encoded body ', async () => {
await driver.move(sample.path.src, sample.path.dest);
expect(driver['toFormUrlEncoded']).toHaveBeenCalledWith({
from_public_id: sample.publicId.src,
to_public_id: sample.publicId.dest,
api_key: sample.config.apiKey,
timestamp: sample.timestamp,
signature: sample.fullSignature,
});
});
test('Fetches URL with url encoded body', async () => {
await driver.move(sample.path.src, sample.path.dest);
expect(fetch).toHaveBeenCalledWith(
`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/${sample.resourceType}/rename`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
body: sample.formUrlEncoded,
}
);
});
test('Throws error if status is >400', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
try {
await driver.move(sample.path.src, sample.path.dest);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`Can't move file "${sample.path.src}": Unknown`);
}
});
test(`Defaults to Unknown if error object doesn't contain message`, async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
mockResponseBody.error = {};
try {
await driver.move(sample.path.src, sample.path.dest);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`Can't move file "${sample.path.src}": Unknown`);
}
});
test(`Renders message if returned by Cloudinary`, async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
mockResponseBody.error = { message: randText() };
try {
await driver.move(sample.path.src, sample.path.dest);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`Can't move file "${sample.path.src}": ${mockResponseBody.error.message}`);
}
});
});
describe('#copy', () => {
beforeEach(() => {
driver.read = vi.fn().mockResolvedValue(sample.stream);
driver.write = vi.fn();
});
test('Calls read with input path', async () => {
await driver.copy(sample.path.src, sample.path.dest);
expect(driver.read).toHaveBeenCalledWith(sample.path.src);
});
test('Calls write with dest path and read stream', async () => {
await driver.copy(sample.path.src, sample.path.dest);
expect(driver.write).toHaveBeenCalledWith(sample.path.dest, sample.stream);
});
});
describe('#write', () => {
let stream: Readable;
let chunks: string[];
beforeEach(async () => {
chunks = randUnique({ length: randNumber({ min: 1, max: 10 }) });
async function* generate() {
for (const chunk of chunks) {
yield chunk;
}
}
stream = Readable.from(generate());
driver['uploadChunk'] = vi.fn();
});
test('Gets full path for input', async () => {
await driver.write(sample.path.input, stream);
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
});
test('Gets resource type for full path', async () => {
await driver.write(sample.path.input, stream);
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.inputFull);
});
test('Constructs signature from correct upload parameters', async () => {
await driver.write(sample.path.input, stream);
expect(driver['getFullSignature']).toHaveBeenCalledWith({
timestamp: sample.timestamp,
api_key: sample.config.apiKey,
type: 'upload',
access_mode: sample.config.accessMode,
public_id: sample.publicId.input,
});
});
test('Queues upload once stream has buffered', async () => {
await driver.write(sample.path.input, stream);
expect(driver['uploadChunk']).toHaveBeenCalledOnce();
expect(driver['uploadChunk']).toHaveBeenCalledWith({
resourceType: sample.resourceType,
blob: new Blob(chunks),
bytesOffset: 0,
bytesTotal: new Blob(chunks).size,
parameters: {
timestamp: sample.timestamp,
access_mode: sample.config.accessMode,
api_key: sample.config.apiKey,
public_id: sample.publicId.input,
signature: sample.fullSignature,
type: 'upload',
},
});
});
test('Queues chunk upload each time chunks add up to at least 5.5MB of data', async () => {
const chunk1 = Buffer.alloc(3e6).toString(); // nothing happens yet
const chunk2 = Buffer.alloc(3e6).toString(); // adds up to 6MB together with chunk1, so sent as chunk
const chunk3 = Buffer.alloc(1e6).toString(); // sent as separate final request
chunks = [chunk1, chunk2, chunk3];
await driver.write(sample.path.input, stream);
expect(driver['uploadChunk']).toHaveBeenCalledTimes(2);
expect(driver['uploadChunk']).toHaveBeenCalledWith({
resourceType: sample.resourceType,
blob: new Blob([chunk1, chunk2]),
bytesOffset: 0,
bytesTotal: -1,
parameters: {
timestamp: sample.timestamp,
access_mode: sample.config.accessMode,
api_key: sample.config.apiKey,
public_id: sample.publicId.input,
signature: sample.fullSignature,
type: 'upload',
},
});
});
test('Sends final chunk with total size information', async () => {
const chunk1 = Buffer.alloc(3e6).toString(); // nothing happens yet
const chunk2 = Buffer.alloc(3e6).toString(); // adds up to 6MB together with chunk1, so sent as chunk
const chunk3 = Buffer.alloc(1e6).toString(); // sent as separate final request
chunks = [chunk1, chunk2, chunk3];
await driver.write(sample.path.input, stream);
expect(driver['uploadChunk']).toHaveBeenCalledTimes(2);
expect(driver['uploadChunk']).toHaveBeenCalledWith({
resourceType: sample.resourceType,
blob: new Blob([chunk3]),
bytesOffset: 6e6,
bytesTotal: 7e6,
parameters: {
timestamp: sample.timestamp,
access_mode: sample.config.accessMode,
api_key: sample.config.apiKey,
public_id: sample.publicId.input,
signature: sample.fullSignature,
type: 'upload',
},
});
});
test('Throws error if one of the chunks failed to upload', async () => {
const chunk1 = Buffer.alloc(3e6).toString(); // nothing happens yet
const chunk2 = Buffer.alloc(3e6).toString(); // adds up to 6MB together with chunk1, so sent as chunk
const chunk3 = Buffer.alloc(1e6).toString(); // sent as separate final request
chunks = [chunk1, chunk2, chunk3];
const mockErrorMessage = randText();
vi.mocked(driver['uploadChunk']).mockRejectedValueOnce(new Error(mockErrorMessage));
vi.mocked(driver['uploadChunk']).mockResolvedValueOnce();
try {
await driver.write(sample.path.input, stream);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`Can't upload file "${sample.path.input}": ${mockErrorMessage}`);
}
});
test('Throws error if last chunk failed to upload', async () => {
const chunk1 = Buffer.alloc(3e6).toString(); // nothing happens yet
const chunk2 = Buffer.alloc(3e6).toString(); // adds up to 6MB together with chunk1, so sent as chunk
const chunk3 = Buffer.alloc(1e6).toString(); // sent as separate final request
chunks = [chunk1, chunk2, chunk3];
const mockErrorMessage = randText();
vi.mocked(driver['uploadChunk']).mockResolvedValueOnce();
vi.mocked(driver['uploadChunk']).mockRejectedValueOnce(new Error(mockErrorMessage));
try {
await driver.write(sample.path.input, stream);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`Can't upload file "${sample.path.input}": ${mockErrorMessage}`);
}
});
});
describe('#uploadChunk', () => {
let mockResponse: { json: Mock; status: number };
let mockResponseBody: {
error?: { message?: string };
};
let mockFormData: {
set: Mock;
};
let input: Parameters<(typeof driver)['uploadChunk']>[0];
beforeEach(() => {
mockResponseBody = {};
mockResponse = {
json: vi.fn().mockResolvedValue(mockResponseBody),
status: 200,
};
mockFormData = {
set: vi.fn(),
};
input = {
resourceType: sample.resourceType,
blob: { size: randNumber({ min: 0, max: 1500 }) } as Blob,
bytesOffset: randNumber({ min: 0, max: 500 }),
bytesTotal: randNumber(),
parameters: {
timestamp: sample.timestamp,
},
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
vi.mocked(FormData).mockReturnValue(mockFormData as unknown as FormData);
});
test('Creates FormData object', async () => {
await driver['uploadChunk'](input);
expect(FormData).toHaveBeenCalledOnce();
expect(FormData).toHaveBeenCalledWith();
});
test('Saves all passed parameters to form data', async () => {
const keys = randUnique({ length: randNumber({ min: 1, max: 10 }) });
const values = randUnique({ length: randNumber({ min: 1, max: 10 }) });
keys.forEach((key, index) => {
input.parameters[key] = values[index]!;
});
await driver['uploadChunk'](input);
keys.forEach((key, index) => {
expect(mockFormData.set).toHaveBeenCalledWith(key, values[index]!);
});
});
test('Calls fetch with formData an range header', async () => {
await driver['uploadChunk'](input);
expect(fetch).toHaveBeenCalledWith(
`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/${sample.resourceType}/upload`,
{
method: 'POST',
body: mockFormData,
headers: {
'X-Unique-Upload-Id': sample.timestamp,
'Content-Range': `bytes ${input.bytesOffset}-${input.bytesOffset + input.blob.size - 1}/${input.bytesTotal}`,
},
}
);
});
test('Throws an error when the response statusCode is >=400', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
try {
await driver['uploadChunk'](input);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe('Unknown');
}
});
test('Defaults to Unknown if Cloudinary API response error message is not known', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
mockResponseBody.error = {};
try {
await driver['uploadChunk'](input);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe('Unknown');
}
});
test('Sets error message to Cloudinary return message', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
mockResponseBody.error = { message: randWord() };
try {
await driver['uploadChunk'](input);
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(mockResponseBody.error.message);
}
});
});
describe('#delete', () => {
beforeEach(async () => {
await driver.delete(sample.path.input);
});
test('Gets full path', () => {
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
});
test('Gets resource type for full path', () => {
expect(driver['getResourceType']).toHaveBeenCalledWith(sample.path.inputFull);
});
test('Gets publicId for full path', () => {
expect(driver['getPublicId']).toHaveBeenCalledWith(sample.path.inputFull);
});
test('Generates signature for delete parameters', () => {
expect(driver['getFullSignature']).toHaveBeenCalledWith({
timestamp: sample.timestamp,
api_key: sample.config.apiKey,
resource_type: sample.resourceType,
public_id: sample.publicId.input,
});
});
test('Calls fetch with correct parameters', () => {
expect(driver['toFormUrlEncoded']).toHaveBeenCalledWith({
timestamp: sample.timestamp,
api_key: sample.config.apiKey,
resource_type: sample.resourceType,
public_id: sample.publicId.input,
signature: sample.fullSignature,
});
expect(fetch).toHaveBeenCalledWith(
`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/${sample.resourceType}/destroy`,
{
method: 'POST',
body: sample.formUrlEncoded,
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
}
);
});
});
describe('#list', () => {
let mockResponse: {
status: number;
json: Mock;
};
let mockFilePaths: string[];
let mockResponseBody: {
resources: { public_id: string }[];
error?: {
message?: string;
};
next_cursor?: string;
};
beforeEach(() => {
mockFilePaths = randFilePath({ length: randNumber({ min: 1, max: 10 }) });
mockResponseBody = {
resources: mockFilePaths.map((filepath) => ({
public_id: filepath,
})),
};
mockResponse = {
status: 200,
json: vi.fn().mockResolvedValue(mockResponseBody),
};
vi.mocked(fetch).mockResolvedValue(mockResponse as unknown as Response);
});
test('Gets full path for prefix', async () => {
await driver.list(sample.path.input).next();
expect(driver['fullPath']).toHaveBeenCalledWith(sample.path.input);
});
test('Gets public id for prefix', async () => {
await driver.list(sample.path.input).next();
expect(driver['getPublicId']).toHaveBeenCalledWith(sample.path.inputFull);
});
test('Fetches search api results', async () => {
await driver.list(sample.path.input).next();
expect(fetch).toHaveBeenCalledWith(
`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/resources/search?expression=${sample.publicId.input}*&next_cursor=`,
{
method: 'GET',
headers: {
Authorization: sample.basicAuth,
},
}
);
});
test('Yields resource public IDs from response', async () => {
const output: string[] = [];
for await (const path of driver.list(sample.path.input)) {
output.push(path);
}
expect(output.length).toBe(mockResponseBody.resources.length);
expect(output).toStrictEqual(mockFilePaths);
});
test('Keeps calling fetch as long as a next_cursor is returned', async () => {
const mockNextCursor = randSha();
mockResponse.json.mockResolvedValueOnce({
...mockResponseBody,
next_cursor: mockNextCursor,
});
const output: string[] = [];
for await (const path of driver.list(sample.path.input)) {
output.push(path);
}
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenCalledWith(
`https://api.cloudinary.com/v1_1/${sample.config.cloudName}/resources/search?expression=${sample.publicId.input}*&next_cursor=${mockNextCursor}`,
{
method: 'GET',
headers: {
Authorization: sample.basicAuth,
},
}
);
});
test('Throws error if search api fails', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
try {
await driver.list(sample.path.input).next();
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`Can't list for prefix "${sample.path.input}": Unknown`);
}
});
test('Defaults to Unknown error if the error response does not contain a message', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
mockResponseBody.error = {};
try {
await driver.list(sample.path.input).next();
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`Can't list for prefix "${sample.path.input}": Unknown`);
}
});
test('Provides Cloudinary error message', async () => {
mockResponse.status = randNumber({ min: 400, max: 599 });
mockResponseBody.error = { message: randText() };
try {
await driver.list(sample.path.input).next();
} catch (err: any) {
expect(err).toBeInstanceOf(Error);
expect(err.message).toBe(`Can't list for prefix "${sample.path.input}": ${mockResponseBody.error.message}`);
}
});
});