Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Add tests for asset upload utility method
  • Loading branch information
zplata committed Apr 18, 2025
commit 86cfe2aa5b9b3c89debfecf471a484ca5a9ff98f
3 changes: 2 additions & 1 deletion 3 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"format": "prettier . --write --ignore-unknown",
"build": "tsc",
"prepack": "cp -rv dist/. .",
"test": "jest"
"test": "jest tests/wrapper/AssetsUtilitiesClient.test.ts"
},
"dependencies": {
"crypto-browserify": "^3.12.1",
Expand All @@ -30,6 +30,7 @@
"@types/url-join": "4.0.1",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest-fetch-mock": "^3.0.3",
"prettier": "2.7.1",
"ts-jest": "29.1.1",
"ts-loader": "^9.3.1",
Expand Down
61 changes: 37 additions & 24 deletions 61 src/wrapper/AssetsUtilitiesClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import urlJoin from "url-join";
import * as Webflow from "../api";
import { Assets } from "../api/resources/assets/client/Client";
import * as core from "../core";
Expand Down Expand Up @@ -27,17 +26,12 @@ export declare namespace AssetsUtilities {

interface TestAssetUploadRequest {
/**
* File to upload via various formats
* File to upload via URL where the asset is hosted, or by ArrayBuffer
*/
file: ArrayBuffer | string;

/**
* The file MIME type
*/
mimeType?: string;

/**
* Name of the file
* Name of the file to upload, including the extension
*/
fileName: string;

Expand All @@ -54,9 +48,15 @@ export class Client extends Assets {
}

private async _getBufferFromUrl(url: string): Promise<ArrayBuffer> {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
return buffer;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch asset from URL: ${url}. Status: ${response.status} ${response.statusText}`);
}
return await response.arrayBuffer();
} catch (error) {
throw new Error(`Error occurred while fetching asset from URL: ${url}. ${(error as Error).message}`);
}
}

/**
Expand All @@ -73,7 +73,7 @@ export class Client extends Assets {
requestOptions?: Assets.RequestOptions
): Promise<Webflow.AssetUpload> {
/** 1. Generate the hash */
const file = request.file;
const {file, fileName, parentFolder} = request;
let tempBuffer: Buffer | null = null;
zplata marked this conversation as resolved.
Outdated
Show resolved Hide resolved
if (typeof file === 'string') {
const arrBuffer = await this._getBufferFromUrl(file);
Expand All @@ -85,21 +85,25 @@ export class Client extends Assets {
throw new Error('Invalid file');
}
const hash = crypto.createHash("md5").update(Buffer.from(tempBuffer)).digest("hex");
zplata marked this conversation as resolved.
Outdated
Show resolved Hide resolved
const fileName = request.fileName;

const wfUploadRequest = {
fileName,
fileHash: hash,
};
} as Webflow.AssetsCreateRequest;
if (parentFolder) {
wfUploadRequest["parentFolder"] = parentFolder;
}
zplata marked this conversation as resolved.
Outdated
Show resolved Hide resolved


/** 2. Create the Asset Metadata in Webflow */
const createWfAssetMetadata = async () => {
return await this.create(siteId, wfUploadRequest, requestOptions);
};
let wfUploadedAsset: Webflow.AssetUpload;
try {
wfUploadedAsset = await this.create(siteId, wfUploadRequest, requestOptions);
} catch (error) {
throw new Error(`Failed to create Asset metadata in Webflow: ${(error as Error).message}`);
}

/** 3. Create FormData with S3 bucket signature */
const wfUploadedAsset = await createWfAssetMetadata();

const wfUploadDetails = wfUploadedAsset.uploadDetails!;
zplata marked this conversation as resolved.
Outdated
Show resolved Hide resolved
const uploadUrl = wfUploadedAsset.uploadUrl as string;
// Temp workaround since headers from response are being camelCased and we need them to be exact when sending to S3
Expand Down Expand Up @@ -132,11 +136,20 @@ export class Client extends Assets {
});

/** 4. Upload to S3 */
await fetch(uploadUrl, {
method: 'POST',
body: formDataToUpload,
headers: {...formDataToUpload.getHeaders()},
});
try {
const response = await fetch(uploadUrl, {
method: 'POST',
body: formDataToUpload,
headers: { ...formDataToUpload.getHeaders() },
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to upload to S3. Status: ${response.status}, Response: ${errorText}`);
}
} catch (error) {
throw new Error(`Error occurred during S3 upload: ${(error as Error).message}`);
}

return wfUploadedAsset;
}
Expand Down
178 changes: 178 additions & 0 deletions 178 tests/wrapper/AssetsUtilitiesClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
require('jest-fetch-mock').enableMocks();
import { Client as AssetsUtilitiesClient } from "../../src/wrapper/AssetsUtilitiesClient";
import * as Webflow from "../../src/api";
import fetchMock from "jest-fetch-mock";
import crypto from "crypto";
import FormDataConstructor from 'form-data';

fetchMock.enableMocks();

describe("AssetsUtilitiesClient", () => {
const mockOptions = {
environment: () => "test-environment",
accessToken: () => "test-access-token",
};

const siteId = "test-site-id";
const mockUploadUrl = "https://mock-s3-upload-url.com";
const mockFileName = "test-file.txt";
const mockFileContent = "Hello, world!";
const mockFileBuffer = Buffer.from(mockFileContent);
const mockFileHash = crypto.createHash("md5").update(mockFileBuffer).digest("hex");

let client: AssetsUtilitiesClient;

beforeEach(() => {
fetchMock.resetMocks();
client = new AssetsUtilitiesClient(mockOptions);
});

it("should throw an error if it cannot fetch the asset successfully", async () => {
const invalidUrl = "https://invalid-url.com";

// Mock the fetch response to simulate a failure
fetchMock.mockResponseOnce("", { status: 404, statusText: "Not Found" });

await expect(client["_getBufferFromUrl"](invalidUrl)).rejects.toThrow(
"Failed to fetch asset from URL: https://invalid-url.com. Status: 404 Not Found"
);

// Ensure fetch was called with the correct URL
expect(fetchMock).toHaveBeenCalledWith(invalidUrl);
});

it("should throw an error for invalid file input", async () => {
await expect(client.createAndUpload(siteId, {
fileName: mockFileName,
file: null as unknown as ArrayBuffer, // Invalid file
})).rejects.toThrow("Invalid file");
});

it("should throw an error if it fails to create Webflow Asset metadata", async () => {
// Mock the Webflow API to throw an error
jest.spyOn(client, "create").mockRejectedValue(new Error("Webflow API error"));

await expect(client.createAndUpload(siteId, {
fileName: mockFileName,
file: mockFileBuffer.buffer, // Pass ArrayBuffer
})).rejects.toThrow("Failed to create Asset metadata in Webflow: Webflow API error");

// Ensure the create method was called
expect(client.create).toHaveBeenCalledWith(siteId, expect.objectContaining({
fileName: mockFileName,
fileHash: expect.any(String),
}), undefined);
});

it("should throw an error if it fails to upload to S3", async () => {
// Mock the Webflow API response for creating asset metadata
const mockCreateResponse = {
uploadUrl: mockUploadUrl,
uploadDetails: {
"xAmzAlgorithm": "AWS4-HMAC-SHA256",
"xAmzDate": "20231010T000000Z",
"xAmzCredential": "mock-credential",
"xAmzSignature": "mock-signature",
"successActionStatus": "201",
"contentType": "text/plain",
},
};
jest.spyOn(client, "create").mockResolvedValue(mockCreateResponse as Webflow.AssetUpload);

// Mock the S3 upload response to fail
fetchMock.mockResponseOnce("S3 upload error", { status: 500 });

await expect(client.createAndUpload(siteId, {
fileName: mockFileName,
file: mockFileBuffer.buffer, // Pass ArrayBuffer
})).rejects.toThrow("Failed to upload to S3. Status: 500, Response: S3 upload error");

// Ensure the S3 upload was attempted
expect(fetchMock).toHaveBeenCalledWith(mockUploadUrl, expect.objectContaining({
method: "POST",
body: expect.any(FormDataConstructor),
}));
});

it("should create and upload a file from an ArrayBuffer", async () => {
// Mock the Webflow API response for creating asset metadata
const mockCreateResponse = {
uploadUrl: mockUploadUrl,
uploadDetails: {
"xAmzAlgorithm": "AWS4-HMAC-SHA256",
"xAmzDate": "20231010T000000Z",
"xAmzCredential": "mock-credential",
"xAmzSignature": "mock-signature",
"successActionStatus": "201",
"contentType": "text/plain",
},
};
jest.spyOn(client, "create").mockResolvedValue(mockCreateResponse as Webflow.AssetUpload);

// Mock the S3 upload response
fetchMock.mockResponseOnce(JSON.stringify({ success: true }), { status: 201 });

const result = await client.createAndUpload(siteId, {
fileName: mockFileName,
file: mockFileBuffer.buffer, // Pass ArrayBuffer
});

// Assertions
expect(client.create).toHaveBeenCalledWith(siteId, expect.objectContaining({
fileName: mockFileName,
fileHash: expect.any(String),
}), undefined);

expect(fetchMock).toHaveBeenCalledWith(mockUploadUrl, expect.objectContaining({
method: "POST",
body: expect.any(FormDataConstructor),
}));

expect(result).toEqual(mockCreateResponse);
});

it("should create and upload a file from a URL", async () => {
// Mock the file fetch response (first fetch call)
fetchMock.mockResponseOnce(mockFileContent);

// Mock the Webflow API response for creating asset metadata
const mockCreateResponse = {
uploadUrl: mockUploadUrl,
uploadDetails: {
"xAmzAlgorithm": "AWS4-HMAC-SHA256",
"xAmzDate": "20231010T000000Z",
"xAmzCredential": "mock-credential",
"xAmzSignature": "mock-signature",
"successActionStatus": "201",
"contentType": "text/plain",
},
};
jest.spyOn(client, "create").mockResolvedValue(mockCreateResponse as Webflow.AssetUpload);

// Mock the S3 upload response (second fetch call)
fetchMock.mockResponseOnce(JSON.stringify({ success: true }), { status: 201 });

const result = await client.createAndUpload(siteId, {
fileName: mockFileName,
file: "https://mock-file-url.com", // Pass asset URL
});

// Assertions for the file fetch
expect(fetchMock).toHaveBeenNthCalledWith(1, "https://mock-file-url.com");

// Assertions for the Webflow API call
expect(client.create).toHaveBeenCalledWith(siteId, {
fileName: mockFileName,
fileHash: mockFileHash,
}, undefined);

// Assertions for the S3 upload
expect(fetchMock).toHaveBeenNthCalledWith(2, mockUploadUrl, expect.objectContaining({
method: "POST",
body: expect.any(FormDataConstructor),
}));

expect(result).toEqual(mockCreateResponse);
});
});

22 changes: 21 additions & 1 deletion 22 yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,13 @@ create-jest@^29.7.0:
jest-util "^29.7.0"
prompts "^2.0.1"

cross-fetch@^3.0.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.2.0.tgz#34e9192f53bc757d6614304d9e5e6fb4edb782e3"
integrity sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==
dependencies:
node-fetch "^2.7.0"

cross-spawn@^7.0.3:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
Expand Down Expand Up @@ -2144,6 +2151,14 @@ jest-environment-node@^29.7.0:
jest-mock "^29.7.0"
jest-util "^29.7.0"

jest-fetch-mock@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b"
integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==
dependencies:
cross-fetch "^3.0.4"
promise-polyfill "^8.1.3"

jest-get-type@^29.6.3:
version "29.6.3"
resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1"
Expand Down Expand Up @@ -2603,7 +2618,7 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==

node-fetch@2.7.0:
node-fetch@2.7.0, node-fetch@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
Expand Down Expand Up @@ -2788,6 +2803,11 @@ process@^0.11.10:
resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==

promise-polyfill@^8.1.3:
version "8.3.0"
resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63"
integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==

prompts@^2.0.1:
version "2.4.2"
resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069"
Expand Down
Morty Proxy This is a proxified and sanitized view of the page, visit original site.