diff --git a/packages/nodes-base/nodes/Baserow/Baserow.node.ts b/packages/nodes-base/nodes/Baserow/Baserow.node.ts index 7f58cf00b1..d87ff7b959 100644 --- a/packages/nodes-base/nodes/Baserow/Baserow.node.ts +++ b/packages/nodes-base/nodes/Baserow/Baserow.node.ts @@ -10,18 +10,22 @@ import { import { baserowApiRequest, + baserowFileUploadRequest, baserowApiRequestAllItems, getJwtToken, TableFieldMapper, toOptions, + getTableFields, } from './GenericFunctions'; import { operationFields } from './OperationDescription'; import type { BaserowCredentials, FieldsUiValues, + FileOperation, GetAllAdditionalOptions, LoadedResource, - Operation, + Resource, + RowOperation, Row, } from './types'; @@ -53,6 +57,10 @@ export class Baserow implements INodeType { type: 'options', noDataExpression: true, options: [ + { + name: 'File', + value: 'file', + }, { name: 'Row', value: 'row', @@ -104,6 +112,48 @@ export class Baserow implements INodeType { ], default: 'getAll', }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['file'], + }, + }, + options: [ + { + name: 'Upload', + value: 'upload', + description: 'Upload a file', + action: 'Upload a file', + }, + { + name: 'Upload via URL', + value: 'upload-via-url', + description: 'Upload a file via URL', + action: 'Upload a file via URL', + }, + ], + default: 'upload', + }, + { + displayName: 'Input Data Field Name', + name: 'binaryPropertyName', + + type: 'string', + default: 'data', + displayOptions: { + show: { + operation: ['upload'], + resource: ['file'], + }, + }, + required: true, + description: + 'The name of the input field containing the binary file data to be uploaded. Supported file types: PNG, JPEG.', + }, ...operationFields, ], }; @@ -155,182 +205,250 @@ export class Baserow implements INodeType { async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); - const mapper = new TableFieldMapper(); - const returnData: INodeExecutionData[] = []; - const operation = this.getNodeParameter('operation', 0) as Operation; - const tableId = this.getNodeParameter('tableId', 0) as string; + const returnData: INodeExecutionData[] = []; + const resource = this.getNodeParameter('resource', 0) as Resource; + const operation = this.getNodeParameter('operation', 0) as RowOperation | FileOperation; + const credentials = await this.getCredentials('baserowApi'); const jwtToken = await getJwtToken.call(this, credentials); - const fields = await mapper.getTableFields.call(this, tableId, jwtToken); - mapper.createMappings(fields); for (let i = 0; i < items.length; i++) { try { - if (operation === 'getAll') { - // ---------------------------------- - // getAll - // ---------------------------------- + if (resource === 'row') { + const tableId = this.getNodeParameter('tableId', 0) as string; + const fields = await getTableFields.call(this, tableId, jwtToken); - // https://api.baserow.io/api/redoc/#operation/list_database_table_rows + const mapper = new TableFieldMapper(); + mapper.createMappings(fields); - const { order, filters, filterType, search } = this.getNodeParameter( - 'additionalOptions', - i, - ) as GetAllAdditionalOptions; + if (operation === 'getAll') { + // ---------------------------------- + // getAll + // ---------------------------------- - const qs: IDataObject = {}; + // https://api.baserow.io/api/redoc/#operation/list_database_table_rows - if (order?.fields) { - qs.order_by = order.fields - .map(({ field, direction }) => `${direction}${mapper.setField(field)}`) - .join(','); - } + const returnAll = this.getNodeParameter('returnAll', 0, false) as boolean; + const limit = this.getNodeParameter('limit', 0, 0); - if (filters?.fields) { - filters.fields.forEach(({ field, operator, value }) => { - qs[`filter__field_${mapper.setField(field)}__${operator}`] = value; - }); - } + const { order, filters, filterType, search } = this.getNodeParameter( + 'additionalOptions', + i, + ) as GetAllAdditionalOptions; - if (filterType) { - qs.filter_type = filterType; - } + const qs: IDataObject = {}; - if (search) { - qs.search = search; - } - - const endpoint = `/api/database/rows/table/${tableId}/`; - const rows = (await baserowApiRequestAllItems.call( - this, - 'GET', - endpoint, - jwtToken, - {}, - qs, - )) as Row[]; - - rows.forEach((row) => mapper.idsToNames(row)); - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(rows), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } else if (operation === 'get') { - // ---------------------------------- - // get - // ---------------------------------- - - // https://api.baserow.io/api/redoc/#operation/get_database_table_row - - const rowId = this.getNodeParameter('rowId', i) as string; - const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`; - const row = await baserowApiRequest.call(this, 'GET', endpoint, jwtToken); - - mapper.idsToNames(row as Row); - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(row as Row), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } else if (operation === 'create') { - // ---------------------------------- - // create - // ---------------------------------- - - // https://api.baserow.io/api/redoc/#operation/create_database_table_row - - const body: IDataObject = {}; - - const dataToSend = this.getNodeParameter('dataToSend', 0) as - | 'defineBelow' - | 'autoMapInputData'; - - if (dataToSend === 'autoMapInputData') { - const incomingKeys = Object.keys(items[i].json); - const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; - const inputDataToIgnore = rawInputsToIgnore.split(',').map((c) => c.trim()); - - for (const key of incomingKeys) { - if (inputDataToIgnore.includes(key)) continue; - body[key] = items[i].json[key]; - mapper.namesToIds(body); + if (order?.fields) { + qs.order_by = order.fields + .map(({ field, direction }) => `${direction}${mapper.setField(field)}`) + .join(','); } - } else { - const fieldsUi = this.getNodeParameter('fieldsUi.fieldValues', i, []) as FieldsUiValues; - for (const field of fieldsUi) { - body[`field_${field.fieldId}`] = field.fieldValue; + + if (filters?.fields) { + filters.fields.forEach(({ field, operator, value }) => { + qs[`filter__field_${mapper.setField(field)}__${operator}`] = value; + }); } + + if (filterType) { + qs.filter_type = filterType; + } + + if (search) { + qs.search = search; + } + + const endpoint = `/api/database/rows/table/${tableId}/`; + const rows = (await baserowApiRequestAllItems.call( + this, + 'GET', + endpoint, + jwtToken, + {}, + qs, + returnAll, + limit, + )) as Row[]; + + rows.forEach((row) => mapper.idsToNames(row)); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(rows), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } else if (operation === 'get') { + // ---------------------------------- + // get + // ---------------------------------- + + // https://api.baserow.io/api/redoc/#operation/get_database_table_row + + const rowId = this.getNodeParameter('rowId', i) as string; + const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`; + const row = await baserowApiRequest.call(this, 'GET', endpoint, jwtToken); + + mapper.idsToNames(row as Row); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(row as Row), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } else if (operation === 'create') { + // ---------------------------------- + // create + // ---------------------------------- + + // https://api.baserow.io/api/redoc/#operation/create_database_table_row + + const body: IDataObject = {}; + + const dataToSend = this.getNodeParameter('dataToSend', 0) as + | 'defineBelow' + | 'autoMapInputData'; + + if (dataToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputDataToIgnore = rawInputsToIgnore.split(',').map((c) => c.trim()); + + for (const key of incomingKeys) { + if (inputDataToIgnore.includes(key)) continue; + body[key] = items[i].json[key]; + mapper.namesToIds(body); + } + } else { + const fieldsUi = this.getNodeParameter( + 'fieldsUi.fieldValues', + i, + [], + ) as FieldsUiValues; + for (const field of fieldsUi) { + body[`field_${field.fieldId}`] = field.fieldValue; + } + } + + const endpoint = `/api/database/rows/table/${tableId}/`; + const createdRow = await baserowApiRequest.call(this, 'POST', endpoint, jwtToken, body); + + mapper.idsToNames(createdRow as Row); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(createdRow as Row), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } else if (operation === 'update') { + // ---------------------------------- + // update + // ---------------------------------- + + // https://api.baserow.io/api/redoc/#operation/update_database_table_row + + const rowId = this.getNodeParameter('rowId', i) as string; + + const body: IDataObject = {}; + + const dataToSend = this.getNodeParameter('dataToSend', 0) as + | 'defineBelow' + | 'autoMapInputData'; + + if (dataToSend === 'autoMapInputData') { + const incomingKeys = Object.keys(items[i].json); + const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; + const inputsToIgnore = rawInputsToIgnore.split(',').map((c) => c.trim()); + + for (const key of incomingKeys) { + if (inputsToIgnore.includes(key)) continue; + body[key] = items[i].json[key]; + mapper.namesToIds(body); + } + } else { + const fieldsUi = this.getNodeParameter( + 'fieldsUi.fieldValues', + i, + [], + ) as FieldsUiValues; + for (const field of fieldsUi) { + body[`field_${field.fieldId}`] = field.fieldValue; + } + } + + const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`; + const updatedRow = await baserowApiRequest.call( + this, + 'PATCH', + endpoint, + jwtToken, + body, + ); + + mapper.idsToNames(updatedRow as Row); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(updatedRow as Row), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } else if (resource === 'row' && operation === 'delete') { + // ---------------------------------- + // delete + // ---------------------------------- + + // https://api.baserow.io/api/redoc/#operation/delete_database_table_row + + const rowId = this.getNodeParameter('rowId', i) as string; + + const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`; + await baserowApiRequest.call(this, 'DELETE', endpoint, jwtToken); + + const executionData = this.helpers.constructExecutionMetaData( + [{ json: { success: true } }], + { itemData: { item: i } }, + ); + returnData.push(...executionData); } + } else if (resource === 'file') { + if (operation === 'upload-via-url') { + // ---------------------------------- + // upload-via-url + // ---------------------------------- - const endpoint = `/api/database/rows/table/${tableId}/`; - const createdRow = await baserowApiRequest.call(this, 'POST', endpoint, jwtToken, body); + // https://api.baserow.io/api/redoc/#tag/User-files/operation/upload_via_url - mapper.idsToNames(createdRow as Row); - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(createdRow as Row), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } else if (operation === 'update') { - // ---------------------------------- - // update - // ---------------------------------- + const url = this.getNodeParameter('url', i) as string; + const endpoint = '/api/user-files/upload-via-url/'; + const body = { url }; + const file = await baserowApiRequest.call(this, 'POST', endpoint, jwtToken, body); - // https://api.baserow.io/api/redoc/#operation/update_database_table_row + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(file), + { itemData: { item: i } }, + ); + returnData.push(...executionData); + } else if (operation === 'upload') { + // ---------------------------------- + // upload + // ---------------------------------- - const rowId = this.getNodeParameter('rowId', i) as string; + // https://api.baserow.io/api/redoc/#tag/User-files/operation/upload_file - const body: IDataObject = {}; + const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i); + const { fileName, mimeType } = this.helpers.assertBinaryData(i, binaryPropertyName); + const binaryDataBuffer = await this.helpers.getBinaryDataBuffer(i, binaryPropertyName); - const dataToSend = this.getNodeParameter('dataToSend', 0) as - | 'defineBelow' - | 'autoMapInputData'; + const file = await baserowFileUploadRequest.call( + this, + jwtToken, + binaryDataBuffer, + fileName as string, + mimeType, + ); - if (dataToSend === 'autoMapInputData') { - const incomingKeys = Object.keys(items[i].json); - const rawInputsToIgnore = this.getNodeParameter('inputsToIgnore', i) as string; - const inputsToIgnore = rawInputsToIgnore.split(',').map((c) => c.trim()); - - for (const key of incomingKeys) { - if (inputsToIgnore.includes(key)) continue; - body[key] = items[i].json[key]; - mapper.namesToIds(body); - } - } else { - const fieldsUi = this.getNodeParameter('fieldsUi.fieldValues', i, []) as FieldsUiValues; - for (const field of fieldsUi) { - body[`field_${field.fieldId}`] = field.fieldValue; - } + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(file), + { itemData: { item: i } }, + ); + returnData.push(...executionData); } - - const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`; - const updatedRow = await baserowApiRequest.call(this, 'PATCH', endpoint, jwtToken, body); - - mapper.idsToNames(updatedRow as Row); - const executionData = this.helpers.constructExecutionMetaData( - this.helpers.returnJsonArray(updatedRow as Row), - { itemData: { item: i } }, - ); - returnData.push(...executionData); - } else if (operation === 'delete') { - // ---------------------------------- - // delete - // ---------------------------------- - - // https://api.baserow.io/api/redoc/#operation/delete_database_table_row - - const rowId = this.getNodeParameter('rowId', i) as string; - - const endpoint = `/api/database/rows/table/${tableId}/${rowId}/`; - await baserowApiRequest.call(this, 'DELETE', endpoint, jwtToken); - - const executionData = this.helpers.constructExecutionMetaData( - [{ json: { success: true } }], - { itemData: { item: i } }, - ); - returnData.push(...executionData); } } catch (error) { if (this.continueOnFail()) { diff --git a/packages/nodes-base/nodes/Baserow/GenericFunctions.ts b/packages/nodes-base/nodes/Baserow/GenericFunctions.ts index ba33755e2f..765bcbc7a8 100644 --- a/packages/nodes-base/nodes/Baserow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Baserow/GenericFunctions.ts @@ -1,20 +1,57 @@ import type { IDataObject, IExecuteFunctions, + IHookFunctions, IHttpRequestMethods, + IHttpRequestOptions, ILoadOptionsFunctions, - IRequestOptions, + IPollFunctions, JsonObject, } from 'n8n-workflow'; import { NodeApiError } from 'n8n-workflow'; import type { Accumulator, BaserowCredentials, LoadedResource } from './types'; +export async function baserowFileUploadRequest( + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions, + jwtToken: string, + file: Buffer, + fileName: string, + mimeType: string, +) { + const credentials = await this.getCredentials('baserowApi'); + + const options: IHttpRequestOptions = { + headers: { + Authorization: `JWT ${jwtToken}`, + 'Content-Type': 'multipart/form-data', + }, + method: 'POST', + url: `${credentials.host}/api/user-files/upload-file/`, + json: false, + body: { + file: { + value: file, + options: { + filename: fileName, + contentType: mimeType, + }, + }, + }, + }; + + try { + return await this.helpers.httpRequest(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error as JsonObject); + } +} + /** * Make a request to Baserow API. */ export async function baserowApiRequest( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, method: IHttpRequestMethods, endpoint: string, jwtToken: string, @@ -23,17 +60,26 @@ export async function baserowApiRequest( ) { const credentials = await this.getCredentials('baserowApi'); - const options: IRequestOptions = { + const options: IHttpRequestOptions = { headers: { Authorization: `JWT ${jwtToken}`, }, method, body, qs, - uri: `${credentials.host}${endpoint}`, + url: `${credentials.host}${endpoint}`, json: true, }; + if (body.formData) { + options.json = false; + options.headers = { + ...options.headers, + 'Content-Type': 'multipart/form-data', + }; + options.returnFullResponse = true; + } + if (Object.keys(qs).length === 0) { delete options.qs; } @@ -43,7 +89,7 @@ export async function baserowApiRequest( } try { - return await this.helpers.request(options); + return await this.helpers.httpRequest(options); } catch (error) { throw new NodeApiError(this.getNode(), error as JsonObject); } @@ -53,27 +99,28 @@ export async function baserowApiRequest( * Get all results from a paginated query to Baserow API. */ export async function baserowApiRequestAllItems( - this: IExecuteFunctions, + this: IExecuteFunctions | IPollFunctions, method: IHttpRequestMethods, endpoint: string, jwtToken: string, body: IDataObject, qs: IDataObject = {}, + returnAll: boolean = false, + limit: number = 0, ): Promise { const returnData: IDataObject[] = []; let responseData; qs.page = 1; - qs.size = 100; - - const returnAll = this.getNodeParameter('returnAll', 0, false); - const limit = this.getNodeParameter('limit', 0, 0); + if (!qs.size) { + qs.size = 100; + } do { responseData = await baserowApiRequest.call(this, method, endpoint, jwtToken, body, qs); returnData.push(...(responseData.results as IDataObject[])); - if (!returnAll && returnData.length > limit) { + if (!returnAll && limit > 0 && returnData.length > limit) { return returnData.slice(0, limit); } @@ -87,21 +134,21 @@ export async function baserowApiRequestAllItems( * Get a JWT token based on Baserow account username and password. */ export async function getJwtToken( - this: IExecuteFunctions | ILoadOptionsFunctions, + this: IExecuteFunctions | ILoadOptionsFunctions | IPollFunctions, { username, password, host }: BaserowCredentials, ) { - const options: IRequestOptions = { + const options: IHttpRequestOptions = { method: 'POST', body: { username, password, }, - uri: `${host}/api/user/token-auth/`, + url: `${host}/api/user/token-auth/`, json: true, }; try { - const { token } = (await this.helpers.request(options)) as { token: string }; + const { token } = (await this.helpers.httpRequest(options)) as { token: string }; return token; } catch (error) { throw new NodeApiError(this.getNode(), error as JsonObject); @@ -127,6 +174,15 @@ export async function getFieldNamesAndIds( }; } +export async function getTableFields( + this: IExecuteFunctions | IPollFunctions, + table: string, + jwtToken: string, +): Promise { + const endpoint = `/api/database/fields/table/${table}/`; + return await baserowApiRequest.call(this, 'GET', endpoint, jwtToken); +} + export const toOptions = (items: LoadedResource[]) => items.map(({ name, id }) => ({ name, value: id })); @@ -140,15 +196,6 @@ export class TableFieldMapper { mapIds = true; - async getTableFields( - this: IExecuteFunctions, - table: string, - jwtToken: string, - ): Promise { - const endpoint = `/api/database/fields/table/${table}/`; - return await baserowApiRequest.call(this, 'GET', endpoint, jwtToken); - } - createMappings(tableFields: LoadedResource[]) { this.nameToIdMapping = this.createNameToIdMapping(tableFields); this.idToNameMapping = this.createIdToNameMapping(tableFields); @@ -181,6 +228,10 @@ export class TableFieldMapper { }); } + idToName(id: string) { + return this.idToNameMapping[id] ?? id; + } + namesToIds(obj: Record) { Object.entries(obj).forEach(([key, value]) => { if (this.nameToIdMapping[key] !== undefined) { @@ -189,4 +240,8 @@ export class TableFieldMapper { } }); } + + nameToId(name: string) { + return this.nameToIdMapping[name] ?? name; + } } diff --git a/packages/nodes-base/nodes/Baserow/OperationDescription.ts b/packages/nodes-base/nodes/Baserow/OperationDescription.ts index 36332d1423..55e7f7f267 100644 --- a/packages/nodes-base/nodes/Baserow/OperationDescription.ts +++ b/packages/nodes-base/nodes/Baserow/OperationDescription.ts @@ -6,6 +6,11 @@ export const operationFields: INodeProperties[] = [ // ---------------------------------- { displayName: 'Database Name or ID', + displayOptions: { + show: { + resource: ['row'], + }, + }, name: 'databaseId', type: 'options', default: '', @@ -18,6 +23,11 @@ export const operationFields: INodeProperties[] = [ }, { displayName: 'Table Name or ID', + displayOptions: { + show: { + resource: ['row'], + }, + }, name: 'tableId', type: 'options', default: '', @@ -442,4 +452,32 @@ export const operationFields: INodeProperties[] = [ }, ], }, + { + displayName: 'File', + displayOptions: { + show: { + resource: ['file'], + operation: ['upload'], + }, + }, + name: 'file', + type: 'resourceLocator', + default: '', + required: true, + description: 'The file to upload', + }, + { + displayName: 'File URL', + displayOptions: { + show: { + resource: ['file'], + operation: ['upload-via-url'], + }, + }, + name: 'url', + type: 'string', + default: '', + required: true, + description: 'The URL of the file to upload', + }, ]; diff --git a/packages/nodes-base/nodes/Baserow/test/GenericFunctions.test.ts b/packages/nodes-base/nodes/Baserow/test/GenericFunctions.test.ts new file mode 100644 index 0000000000..a0fbff8fdb --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/GenericFunctions.test.ts @@ -0,0 +1,173 @@ +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + IN8nHttpFullResponse, + IN8nHttpResponse, + INode, +} from 'n8n-workflow'; + +import { baserowApiRequest, baserowFileUploadRequest, getJwtToken } from '../GenericFunctions'; +import { username } from 'minifaker'; +import { httpRequest } from 'n8n-core'; + +export const node: INode = { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow Upload', + type: 'n8n-nodes-base.Baserow', + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'upload', + domain: 'file', + }, +}; + +describe('Baserow GenericFunctions', () => { + describe('getJwtToken', () => { + const mockThis = { + helpers: { + httpRequest: jest.fn().mockResolvedValue({ + token: 'jwt', + }), + }, + } as unknown as IExecuteFunctions | ILoadOptionsFunctions; + + it('should return the JWT token', async () => { + const jwtToken = await getJwtToken.call(mockThis, { + username: 'user', + password: 'password', + host: 'https://my-host.com', + }); + + expect(jwtToken).toBe('jwt'); + }); + }); + + describe('baserowApiRequest', () => { + const mockThis = { + helpers: { + requestWithAuthentication: jest.fn().mockResolvedValue({ statusCode: 200 }), + httpRequest: jest.fn(), + }, + getNode() { + return node; + }, + getCredentials: jest.fn(), + getNodeParameter: jest.fn(), + } as unknown as IExecuteFunctions | ILoadOptionsFunctions; + + it('should upload a file via url', async () => { + mockThis.getCredentials.mockResolvedValue({ + host: 'https://my-host.com', + }); + + mockThis.helpers.httpRequest.mockResolvedValue({ + statusCode: 200, + body: { + size: 2147483647, + mime_type: 'string', + is_image: true, + image_width: 32767, + image_height: 32767, + uploaded_at: '2019-08-24T14:15:22Z', + url: 'http://example.com', + thumbnails: { + property1: null, + property2: null, + }, + name: 'string', + original_name: 'string', + }, + } as IN8nHttpFullResponse | IN8nHttpResponse); + + const jwtToken = 'jwt'; + const fileUrl = 'http://example.com/image.png'; + + const body = { + file_url: fileUrl, + }; + + await baserowApiRequest.call( + mockThis, + 'POST', + '/api/user-files/upload-via-url/', + jwtToken, + body, + ); + + expect(mockThis.getCredentials).toHaveBeenCalledWith('baserowApi'); + expect(mockThis.helpers.httpRequest).toHaveBeenCalledWith({ + headers: { + Authorization: `JWT ${jwtToken}`, + }, + method: 'POST', + url: 'https://my-host.com/api/user-files/upload-via-url/', + body, + json: true, + }); + }); + }); + + describe('baserowFileUploadRequest', () => { + const mockThis = { + helpers: { + requestWithAuthentication: jest.fn().mockResolvedValue({ statusCode: 200 }), + httpRequest: jest.fn(), + }, + getNode() { + return node; + }, + getCredentials: jest.fn(), + getNodeParameter: jest.fn(), + } as unknown as IExecuteFunctions | ILoadOptionsFunctions; + + it('should make an authenticated API file-upload request to Baserow', async () => { + mockThis.getCredentials.mockResolvedValue({ + host: 'https://my-host.com', + }); + + // see https://api.baserow.io/api/redoc/#tag/User-files/operation/upload_file + mockThis.helpers.httpRequest.mockResolvedValue({ + statusCode: 200, + body: { + size: 2147483647, + mime_type: 'string', + is_image: true, + image_width: 32767, + image_height: 32767, + uploaded_at: '2019-08-24T14:15:22Z', + url: 'http://example.com', + thumbnails: { + property1: null, + property2: null, + }, + name: 'string', + original_name: 'string', + }, + } as IN8nHttpFullResponse | IN8nHttpResponse); + + const jwtToken = 'jwt'; + const file = Buffer.from('file'); + const filename = 'filename.txt'; + const mimeType = 'text/plain'; + + await baserowFileUploadRequest.call(mockThis, jwtToken, file, filename, mimeType); + + expect(mockThis.getCredentials).toHaveBeenCalledWith('baserowApi'); + + expect(mockThis.helpers.httpRequest).toHaveBeenCalledWith({ + body: { + file: { + options: { contentType: 'text/plain', filename: 'filename.txt' }, + value: file, + }, + }, + headers: { Authorization: 'JWT jwt', 'Content-Type': 'multipart/form-data' }, + json: false, + method: 'POST', + url: 'https://my-host.com/api/user-files/upload-file/', + }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/test/node/file/upload-via-url.test.ts b/packages/nodes-base/nodes/Baserow/test/node/file/upload-via-url.test.ts new file mode 100644 index 0000000000..3c454f1606 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/node/file/upload-via-url.test.ts @@ -0,0 +1,143 @@ +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { Baserow } from '../../../Baserow.node'; +import { baserowApiRequest, getTableFields } from '../../../GenericFunctions'; +import type { GetAllAdditionalOptions } from '../../../types'; + +jest.mock('../../../GenericFunctions', () => { + const originalModule: { [key: string]: any } = jest.requireActual('../../../GenericFunctions'); + return { + ...originalModule, + baserowApiRequest: jest.fn().mockResolvedValue({ + size: 2147483647, + mime_type: 'string', + is_image: true, + image_width: 32767, + image_height: 32767, + uploaded_at: '2019-08-24T14:15:22Z', + url: 'http://example.com', + thumbnails: { + property1: null, + property2: null, + }, + name: 'string', + original_name: 'string', + }), + getJwtToken: jest.fn().mockResolvedValue('jwt'), + }; +}); + +describe('Baserow Node', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../GenericFunctions'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resource: file', () => { + it('upload-via-url should upload a file via url', async () => { + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + httpRequest: jest.fn().mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }, + }), + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow upload-via-url', + type: 'n8n-nodes-base.Baserow', + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'upload-via-url', + resource: 'file', + tableId: 1, + rowId: 1, + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: {}, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'resource': + return 'file'; + case 'operation': + return 'upload-via-url'; + case 'url': + return 'https://example.com/image.jpg'; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + const node = new Baserow(); + const response: INodeExecutionData[][] = await node.execute.call(mockThis); + expect(baserowApiRequest).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenNthCalledWith( + 1, + 'POST', + '/api/user-files/upload-via-url/', + 'jwt', + { url: 'https://example.com/image.jpg' }, + ); + + expect(response).toEqual([ + [ + { + json: { + image_height: 32767, + image_width: 32767, + is_image: true, + mime_type: 'string', + name: 'string', + original_name: 'string', + size: 2147483647, + thumbnails: { property1: null, property2: null }, + uploaded_at: '2019-08-24T14:15:22Z', + url: 'http://example.com', + }, + pairedItem: { item: 0 }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/test/node/file/upload.test.ts b/packages/nodes-base/nodes/Baserow/test/node/file/upload.test.ts new file mode 100644 index 0000000000..9a5a832b46 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/node/file/upload.test.ts @@ -0,0 +1,156 @@ +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { Baserow } from '../../../Baserow.node'; +import { baserowFileUploadRequest } from '../../../GenericFunctions'; +import type { GetAllAdditionalOptions } from '../../../types'; + +jest.mock('../../../GenericFunctions', () => { + const originalModule: { [key: string]: any } = jest.requireActual('../../../GenericFunctions'); + return { + ...originalModule, + baserowFileUploadRequest: jest.fn().mockResolvedValue({ + size: 2147483647, + mime_type: 'string', + is_image: true, + image_width: 32767, + image_height: 32767, + uploaded_at: '2019-08-24T14:15:22Z', + url: 'http://example.com', + thumbnails: { + property1: null, + property2: null, + }, + name: 'string', + original_name: 'string', + }), + getJwtToken: jest.fn().mockResolvedValue('jwt'), + }; +}); + +describe('Baserow Node', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../GenericFunctions'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resource: file', () => { + it('upload should upload a file', async () => { + const buffer = Buffer.from('test'); + + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + assertBinaryData: jest + .fn() + .mockReturnValue({ fileName: 'test.png', mimeType: 'image/png' }), + getBinaryDataBuffer: jest.fn().mockResolvedValue(buffer), + httpRequest: jest.fn().mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }, + }), + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow upload', + type: 'n8n-nodes-base.Baserow', + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'upload', + resource: 'file', + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: {}, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'resource': + return 'file'; + case 'operation': + return 'upload'; + case 'url': + return 'https://example.com/image.jpg'; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + case 'binaryPropertyName': + return 'data'; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + const node = new Baserow(); + const response: INodeExecutionData[][] = await node.execute.call(mockThis); + + expect(mockThis.helpers.assertBinaryData).toHaveBeenCalledTimes(1); + expect(mockThis.helpers.getBinaryDataBuffer).toHaveBeenCalledTimes(1); + + expect(baserowFileUploadRequest).toHaveBeenCalledTimes(1); + expect(baserowFileUploadRequest).toHaveBeenNthCalledWith( + 1, + 'jwt', + buffer, + 'test.png', + 'image/png', + ); + + expect(response).toEqual([ + [ + { + json: { + size: 2147483647, + mime_type: 'string', + is_image: true, + image_width: 32767, + image_height: 32767, + uploaded_at: '2019-08-24T14:15:22Z', + url: 'http://example.com', + thumbnails: { + property1: null, + property2: null, + }, + name: 'string', + original_name: 'string', + }, + pairedItem: { item: 0 }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/test/node/rows/create.test.ts b/packages/nodes-base/nodes/Baserow/test/node/rows/create.test.ts new file mode 100644 index 0000000000..dfb2e211a4 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/node/rows/create.test.ts @@ -0,0 +1,140 @@ +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { Baserow } from '../../../Baserow.node'; +import { baserowApiRequest, getTableFields } from '../../../GenericFunctions'; +import type { GetAllAdditionalOptions } from '../../../types'; + +jest.mock('../../../GenericFunctions', () => { + const originalModule: { [key: string]: any } = jest.requireActual('../../../GenericFunctions'); + return { + ...originalModule, + baserowApiRequest: jest.fn().mockResolvedValue({ + id: 1, + field_1: 'baz', + }), + getJwtToken: jest.fn().mockResolvedValue('jwt'), + getTableFields: jest.fn().mockResolvedValue([ + { + id: '1', + name: 'my_field_name', + }, + ]), + }; +}); + +describe('Baserow Node', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../GenericFunctions'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resource: row', () => { + it('create should create a record', async () => { + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + httpRequest: jest.fn().mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }, + }), + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow get', + type: 'n8n-nodes-base.Baserow', + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'create', + resource: 'row', + tableId: 1, + rowId: 1, + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: { + my_field_name: 'new value', + }, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'resource': + return 'row'; + case 'operation': + return 'create'; + case 'tableId': + return 1; + case 'dataToSend': + return 'autoMapInputData'; + case 'inputsToIgnore': + return ''; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + const node = new Baserow(); + const response: INodeExecutionData[][] = await node.execute.call(mockThis); + + expect(getTableFields).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenNthCalledWith( + 1, + 'POST', + '/api/database/rows/table/1/', + 'jwt', + { field_1: 'new value' }, + ); + + expect(response).toEqual([ + [ + { + json: { + id: 1, + my_field_name: 'baz', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/test/node/rows/delete.test.ts b/packages/nodes-base/nodes/Baserow/test/node/rows/delete.test.ts new file mode 100644 index 0000000000..e8d3fd5064 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/node/rows/delete.test.ts @@ -0,0 +1,125 @@ +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { Baserow } from '../../../Baserow.node'; +import { baserowApiRequest, getTableFields } from '../../../GenericFunctions'; +import type { GetAllAdditionalOptions } from '../../../types'; + +jest.mock('../../../GenericFunctions', () => { + const originalModule: { [key: string]: any } = jest.requireActual('../../../GenericFunctions'); + return { + ...originalModule, + baserowApiRequest: jest.fn().mockResolvedValue({ + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }), + getJwtToken: jest.fn().mockResolvedValue('jwt'), + getTableFields: jest.fn().mockResolvedValue([ + { + id: '1', + name: 'my_field_name', + }, + ]), + }; +}); + +describe('Baserow Node', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../GenericFunctions'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resource: row', () => { + it('delete should delete a record', async () => { + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow delete', + type: 'n8n-nodes-base.Baserow', + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'delete', + resource: 'row', + tableId: 1, + rowId: 1, + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: {}, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'resource': + return 'row'; + case 'operation': + return 'delete'; + case 'tableId': + return 1; + case 'rowId': + return 1; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + const node = new Baserow(); + const response: INodeExecutionData[][] = await node.execute.call(mockThis); + + expect(getTableFields).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenNthCalledWith( + 1, + 'DELETE', + '/api/database/rows/table/1/1/', + 'jwt', + ); + + expect(response).toEqual([ + [ + { + json: { + success: true, + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/test/node/rows/get.test.ts b/packages/nodes-base/nodes/Baserow/test/node/rows/get.test.ts new file mode 100644 index 0000000000..495700a28e --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/node/rows/get.test.ts @@ -0,0 +1,137 @@ +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { Baserow } from '../../../Baserow.node'; +import { baserowApiRequest, getTableFields } from '../../../GenericFunctions'; +import type { GetAllAdditionalOptions } from '../../../types'; + +jest.mock('../../../GenericFunctions', () => { + const originalModule: { [key: string]: any } = jest.requireActual('../../../GenericFunctions'); + return { + ...originalModule, + baserowApiRequest: jest.fn().mockResolvedValue({ + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }), + getJwtToken: jest.fn().mockResolvedValue('jwt'), + getTableFields: jest.fn().mockResolvedValue([ + { + id: '1', + name: 'my_field_name', + }, + ]), + }; +}); + +describe('Baserow Node', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../GenericFunctions'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resource: row', () => { + it('get should fetch a record', async () => { + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + httpRequest: jest.fn().mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }, + }), + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow get', + type: 'n8n-nodes-base.Baserow', + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'get', + resource: 'row', + tableId: 1, + rowId: 1, + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: {}, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'resource': + return 'row'; + case 'operation': + return 'get'; + case 'tableId': + return 1; + case 'rowId': + return 1; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + const node = new Baserow(); + const response: INodeExecutionData[][] = await node.execute.call(mockThis); + + expect(getTableFields).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenNthCalledWith( + 1, + 'GET', + '/api/database/rows/table/1/1/', + 'jwt', + ); + + expect(response).toEqual([ + [ + { + json: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + my_field_name: 'baz', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/test/node/rows/getAll.test.ts b/packages/nodes-base/nodes/Baserow/test/node/rows/getAll.test.ts new file mode 100644 index 0000000000..81dcb4057e --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/node/rows/getAll.test.ts @@ -0,0 +1,123 @@ +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { Baserow } from '../../../Baserow.node'; +import { getTableFields } from '../../../GenericFunctions'; +import type { GetAllAdditionalOptions } from '../../../types'; + +jest.mock('../../../GenericFunctions', () => { + const originalModule: { [key: string]: any } = jest.requireActual('../../../GenericFunctions'); + return { + ...originalModule, + getJwtToken: jest.fn().mockResolvedValue('jwt'), + getTableFields: jest.fn().mockResolvedValue([ + { + id: '1', + name: 'my_field_name', + }, + ]), + }; +}); + +describe('Baserow Node', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../GenericFunctions'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resource: row', () => { + it('getAll should fetch all records', async () => { + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + httpRequest: jest.fn().mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [ + { + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }, + ], + }), + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow getAll', + type: 'n8n-nodes-base.Baserow', + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'getAll', + resource: 'row', + tableId: 1, + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: {}, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'resource': + return 'row'; + case 'operation': + return 'getAll'; + case 'tableId': + return 1; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + const node = new Baserow(); + const response: INodeExecutionData[][] = await node.execute.call(mockThis); + + expect(getTableFields).toHaveBeenCalledTimes(1); + expect(response).toEqual([ + [ + { + json: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + my_field_name: 'baz', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/test/node/rows/update.test.ts b/packages/nodes-base/nodes/Baserow/test/node/rows/update.test.ts new file mode 100644 index 0000000000..85ce78cc78 --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/node/rows/update.test.ts @@ -0,0 +1,144 @@ +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { Baserow } from '../../../Baserow.node'; +import { baserowApiRequest, getTableFields } from '../../../GenericFunctions'; +import type { GetAllAdditionalOptions } from '../../../types'; + +jest.mock('../../../GenericFunctions', () => { + const originalModule: { [key: string]: any } = jest.requireActual('../../../GenericFunctions'); + return { + ...originalModule, + baserowApiRequest: jest.fn().mockResolvedValue({ + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'changed', + }), + getJwtToken: jest.fn().mockResolvedValue('jwt'), + getTableFields: jest.fn().mockResolvedValue([ + { + id: '1', + name: 'my_field_name', + }, + ]), + }; +}); + +describe('Baserow Node', () => { + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../GenericFunctions'); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('resource: row', () => { + it('update should update a record', async () => { + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + httpRequest: jest.fn().mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }, + }), + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow get', + type: 'n8n-nodes-base.Baserow', + typeVersion: 1, + position: [0, 0], + parameters: { + operation: 'update', + resource: 'row', + tableId: 1, + rowId: 1, + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: { + my_field_name: 'changed', + }, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'resource': + return 'row'; + case 'operation': + return 'update'; + case 'tableId': + return 1; + case 'rowId': + return 1; + case 'dataToSend': + return 'autoMapInputData'; + case 'inputsToIgnore': + return ''; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + const node = new Baserow(); + const response: INodeExecutionData[][] = await node.execute.call(mockThis); + + expect(getTableFields).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenCalledTimes(1); + expect(baserowApiRequest).toHaveBeenNthCalledWith( + 1, + 'PATCH', + '/api/database/rows/table/1/1/', + 'jwt', + { field_1: 'changed' }, + ); + + expect(response).toEqual([ + [ + { + json: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + my_field_name: 'changed', + }, + pairedItem: { + item: 0, + }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/test/node/trigger/trigger.test.ts b/packages/nodes-base/nodes/Baserow/test/node/trigger/trigger.test.ts new file mode 100644 index 0000000000..ad48dfa8ca --- /dev/null +++ b/packages/nodes-base/nodes/Baserow/test/node/trigger/trigger.test.ts @@ -0,0 +1,232 @@ +import { constructExecutionMetaData, returnJsonArray } from 'n8n-core'; +import type { + IExecuteFunctions, + IHookFunctions, + ILoadOptionsFunctions, + INode, + INodeExecutionData, +} from 'n8n-workflow'; +import nock from 'nock'; + +import { BaserowTrigger } from '../../../BaserowTrigger.node'; +import { baserowApiRequestAllItems, getTableFields } from '../../../GenericFunctions'; +import type { GetAllAdditionalOptions } from '../../../types'; + +jest.mock('../../../GenericFunctions', () => { + const originalModule: { [key: string]: any } = jest.requireActual('../../../GenericFunctions'); + return { + ...originalModule, + baserowApiRequestAllItems: jest.fn(), + getJwtToken: jest.fn().mockResolvedValue('jwt'), + getTableFields: jest.fn(), + }; +}); + +describe('Baserow Trigger Node', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2024-12-10T12:57:41Z')); + }); + + beforeAll(() => { + nock.disableNetConnect(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(() => { + nock.restore(); + jest.unmock('../../../GenericFunctions'); + }); + + describe('trigger', () => { + it('should not trigger when no new record', async () => { + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + httpRequest: jest.fn().mockResolvedValue({ + count: 0, + next: null, + previous: null, + results: [], + }), + }, + getWorkflowStaticData() { + return { + node: {}, + }; + }, + getMode() { + return 'auto'; + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow Trigger', + type: 'n8n-nodes-base.baserowTrigger', + typeVersion: 1, + position: [0, 0], + parameters: { + databaseId: 1, + tableId: 1, + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: {}, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'databaseId': + return 1; + case 'tableId': + return 1; + case 'additionalFields': + return { viewId: 1 }; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + getTableFields.mockResolvedValue([ + { + id: '1', + name: 'my_field_name', + }, + ]); + + baserowApiRequestAllItems.mockResolvedValue([]); + + const node = new BaserowTrigger(); + const response: INodeExecutionData[][] | null = await node.poll.call(mockThis); + + expect(baserowApiRequestAllItems).toHaveBeenCalledTimes(1); + expect(getTableFields).toHaveBeenCalled(); + + expect(response).toBeNull(); + }); + it('should trigger when new record', async () => { + const mockThis = { + helpers: { + returnJsonArray, + constructExecutionMetaData, + httpRequest: jest.fn().mockResolvedValue({ + count: 1, + next: null, + previous: null, + results: [ + { + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }, + ], + }), + }, + getMode() { + return 'auto'; + }, + getWorkflowStaticData() { + return { + node: {}, + }; + }, + getNode() { + return { + id: 'c4a5ca75-18c7-4cc8-bf7d-5d57bb7d84da', + name: 'Baserow Trigger', + type: 'n8n-nodes-base.baserowTrigger', + typeVersion: 1, + position: [0, 0], + parameters: { + databaseId: 1, + tableId: 1, + }, + } as INode; + }, + getCredentials: jest.fn().mockResolvedValue({ + username: 'user', + password: 'password', + host: 'https://my-host.com', + }), + getInputData: () => [ + { + json: {}, + }, + ], + getNodeParameter: (parameter: string) => { + switch (parameter) { + case 'databaseId': + return 1; + case 'tableId': + return 1; + case 'triggerField': + return 'changed_at'; + case 'additionalOptions': + return {} as GetAllAdditionalOptions; + case 'additionalFields': + return { viewId: 1 }; + default: + return undefined; + } + }, + continueOnFail: () => false, + } as unknown as IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions; + + baserowApiRequestAllItems.mockResolvedValue([ + { + id: 1, + order: '^-?\\(?:\\.\\)?$', + field_1: 'baz', + }, + ]); + + getTableFields.mockResolvedValue([ + { + id: '1', + name: 'my_field_name', + }, + ]); + + const node = new BaserowTrigger(); + const response: INodeExecutionData[][] | null = await node.poll.call(mockThis); + + expect(getTableFields).toHaveBeenCalledTimes(1); + expect(baserowApiRequestAllItems).toHaveBeenCalledTimes(1); + expect(baserowApiRequestAllItems).toHaveBeenNthCalledWith( + 1, + 'GET', + '/api/database/rows/table/1/', + undefined, + {}, + { filter__changed_at__date_after: 'Africa/Abidjan?2024-12-10T12:57:41Z', view_id: 1 }, + ); + + expect(response).toEqual([ + [ + { + json: { + id: 1, + order: '^-?\\(?:\\.\\)?$', + my_field_name: 'baz', + }, + }, + ], + ]); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Baserow/types.ts b/packages/nodes-base/nodes/Baserow/types.ts index bb340d3231..a4ea3a6af9 100644 --- a/packages/nodes-base/nodes/Baserow/types.ts +++ b/packages/nodes-base/nodes/Baserow/types.ts @@ -35,7 +35,9 @@ export type Row = Record; export type FieldsUiValues = Array<{ fieldId: string; - fieldValue: string; + fieldValue: string | string[]; }>; -export type Operation = 'create' | 'delete' | 'update' | 'get' | 'getAll'; +export type Resource = 'file' | 'row' | 'table' | 'database'; +export type RowOperation = 'create' | 'delete' | 'update' | 'get' | 'getAll'; +export type FileOperation = 'upload' | 'upload-via-url';