Worked on credentials, tests and fields for both resources

This commit is contained in:
Adina Totorean 2025-01-29 10:00:34 +02:00
parent 6307ac162f
commit 541f289466
8 changed files with 578 additions and 582 deletions

View file

@ -33,12 +33,27 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType {
},
default: '',
},
{
displayName: 'Database',
name: 'database',
description: 'Database name',
type: 'string',
default: '',
},
{
displayName: 'Base URL',
name: 'baseUrl',
type: 'hidden',
default: '=https://{{ $self["account"] }}.documents.azure.com/dbs/{{ $self["database"] }}',
},
];
async authenticate(
credentials: ICredentialDataDecryptedObject,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
console.log('Authenticate invoked with requestOptions:', requestOptions);
if (requestOptions.qs) {
for (const [key, value] of Object.entries(requestOptions.qs)) {
if (value === undefined) {
@ -52,7 +67,7 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType {
requestOptions.headers = {
...requestOptions.headers,
'x-ms-date': date,
'x-ms-version': '2020-04-08',
'x-ms-version': '2018-12-31',
};
if (credentials.sessionToken) {
@ -60,40 +75,16 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType {
}
let resourceType = '';
let resourceLink = '';
if (requestOptions.body && typeof requestOptions.body === 'object') {
const isCollectionRequest = 'colls' in requestOptions.body;
const isDocumentRequest = 'docs' in requestOptions.body;
const resourceLink = requestOptions.url;
if (isCollectionRequest) {
resourceType = 'dbs';
resourceLink = `dbs/${credentials.database}/colls`;
} else if (isDocumentRequest) {
resourceType = 'colls';
const collId = requestOptions.qs?.collId || '';
if (!collId) {
throw new ApplicationError('Collection ID (collId) is required for document requests.');
}
resourceLink = `dbs/${credentials.database}/colls/${collId}/docs`;
}
} else if (requestOptions.qs && typeof requestOptions.qs === 'object') {
const queryType = requestOptions.qs.queryType;
if (queryType === 'colls') {
resourceType = 'dbs';
resourceLink = `dbs/${credentials.database}/colls`;
} else if (queryType === 'docs') {
resourceType = 'colls';
const collId = requestOptions.qs.collId || '';
if (!collId) {
throw new ApplicationError('Collection ID (collId) is required for document queries.');
}
resourceLink = `dbs/${credentials.database}/colls/${collId}/docs`;
}
if (resourceLink.includes('/colls')) {
resourceType = 'colls';
} else if (resourceLink.includes('/docs')) {
resourceType = 'docs';
} else if (resourceLink.includes('/dbs')) {
resourceType = 'dbs';
} else {
throw new ApplicationError(
'Invalid requestOptions: Either body or query string (qs) is required.',
);
throw new ApplicationError('Unable to determine resourceType');
}
if (requestOptions.method) {
@ -105,9 +96,11 @@ export class AzureCosmosDbSharedKeyApi implements ICredentialType {
credentials.key as string,
);
requestOptions.headers.authorization = authToken;
requestOptions.headers.Authorization = authToken;
}
console.log('Final requestOptions headers:', requestOptions.headers);
return requestOptions;
}
}

View file

@ -3,7 +3,7 @@ import { NodeConnectionType } from 'n8n-workflow';
import { containerFields, containerOperations } from './descriptions/ContainerDescription';
import { itemFields, itemOperations } from './descriptions/ItemDescription';
import { searchCollections, searchDatabases } from './GenericFunctions';
import { searchCollections } from './GenericFunctions';
export class AzureCosmosDb implements INodeType {
description: INodeTypeDescription = {
@ -34,9 +34,10 @@ export class AzureCosmosDb implements INodeType {
},
],
requestDefaults: {
baseURL: '=https://{$credentials.account}.documents.azure.com',
baseURL: '={{$credentials.baseUrl}}',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
},
properties: [
@ -79,7 +80,6 @@ export class AzureCosmosDb implements INodeType {
methods = {
listSearch: {
searchCollections,
searchDatabases,
},
};
}

View file

@ -10,7 +10,6 @@ import type {
INodeListSearchResult,
} from 'n8n-workflow';
import { ApplicationError } from 'n8n-workflow';
import * as querystring from 'querystring';
// export const HeaderConstants = {
// // Required
@ -49,7 +48,6 @@ export function getAuthorizationTokenUsingMasterKey(
masterKey: string,
): string {
const key = Buffer.from(masterKey, 'base64');
const payload =
`${verb.toLowerCase()}\n` +
`${resourceType.toLowerCase()}\n` +
@ -60,7 +58,7 @@ export function getAuthorizationTokenUsingMasterKey(
const hmacSha256 = crypto.createHmac('sha256', key);
const hashPayload = hmacSha256.update(payload, 'utf8').digest('base64');
const authorizationString = querystring.escape(`type=master&ver=1.0&sig=${hashPayload}`);
const authorizationString = `type=master&ver=1.0&sig=${hashPayload}`;
return authorizationString;
}
@ -134,6 +132,10 @@ export async function azureCosmosDbRequest(
const requestOptions: IHttpRequestOptions = {
...opts,
baseURL: `https://${databaseAccount}.documents.azure.com`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
json: true,
};
@ -150,6 +152,8 @@ export async function azureCosmosDbRequest(
};
try {
console.log('Final Request Options before Request:', requestOptions);
return (await this.helpers.requestWithAuthentication.call(
this,
'azureCosmosDbSharedKeyApi',
@ -188,25 +192,9 @@ export async function searchCollections(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const dbId = this.getNodeParameter('dbId') as string;
if (!dbId) {
throw new ApplicationError('Database ID is required');
}
const credentials = await this.getCredentials('azureCosmosDbSharedKeyApi');
const databaseAccount = credentials?.account;
if (!databaseAccount) {
throw new ApplicationError('Account name not found in credentials!', { level: 'error' });
}
const opts: IHttpRequestOptions = {
method: 'GET',
url: `/dbs/${dbId}/colls`,
baseURL: `https://${databaseAccount}.documents.azure.com`,
headers: {
'Content-Type': 'application/json',
},
url: '/colls',
};
const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts);
@ -233,38 +221,36 @@ export async function searchCollections(
};
}
export async function searchDatabases(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const opts: IHttpRequestOptions = {
method: 'GET',
url: '/dbs',
headers: {
'Content-Type': 'application/json',
},
};
// export async function searchDatabases(
// this: ILoadOptionsFunctions,
// filter?: string,
// ): Promise<INodeListSearchResult> {
const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts);
// const opts: IHttpRequestOptions = {
// method: 'GET',
// url: '/dbs',
// };
const responseBody = responseData as {
Databases: IDataObject[];
};
const databases = responseBody.Databases;
// const responseData: IDataObject = await azureCosmosDbRequest.call(this, opts);
// console.log('Got this response', responseData)
// const responseBody = responseData as {
// Databases: IDataObject[];
// };
// const databases = responseBody.Databases;
if (!databases) {
return { results: [] };
}
// if (!databases) {
// return { results: [] };
// }
const results: INodeListSearchItems[] = databases
.map((database) => ({
name: String(database.id),
value: String(database.id),
}))
.filter((database) => !filter || database.name.includes(filter))
.sort((a, b) => a.name.localeCompare(b.name));
// const results: INodeListSearchItems[] = databases
// .map((database) => ({
// name: String(database.id),
// value: String(database.id),
// }))
// .filter((database) => !filter || database.name.includes(filter))
// .sort((a, b) => a.name.localeCompare(b.name));
return {
results,
};
}
// return {
// results,
// };
// }

View file

@ -20,7 +20,7 @@ export const containerOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'POST',
url: '=/dbs/{{ $parameter["dbId"] }}/colls',
url: '/colls',
},
},
action: 'Create container',
@ -33,7 +33,7 @@ export const containerOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'DELETE',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}',
url: '=/colls/{{ $parameter["collId"] }}',
},
},
action: 'Delete container',
@ -46,7 +46,7 @@ export const containerOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'GET',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}',
url: '=/colls/{{ $parameter["collId"] }}',
},
},
action: 'Get container',
@ -59,7 +59,7 @@ export const containerOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'GET',
url: '=/dbs/{{ $parameter["dbId"] }}/colls',
url: '/colls',
},
},
action: 'Get many containers',
@ -70,50 +70,6 @@ export const containerOperations: INodeProperties[] = [
];
export const createFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['container'],
operation: ['create'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'ID',
name: 'id',
@ -185,6 +141,45 @@ export const createFields: INodeProperties[] = [
},
},
},
{
displayName: 'Max RU/s (for Autoscale)',
name: 'maxThroughput',
type: 'number',
default: 1000,
description: 'The user specified autoscale max RU/s',
displayOptions: {
show: {
offerThroughput: [undefined],
},
},
routing: {
send: {
type: 'query',
property: 'x-ms-cosmos-offer-autopilot-settings',
value: '={{"{"maxThroughput": " + $value + "}"}',
},
},
},
{
displayName: 'Max RU/s (for Manual Throughput)',
name: 'offerThroughput',
type: 'number',
default: 400,
description:
'The user specified manual throughput (RU/s) for the collection expressed in units of 100 request units per second',
displayOptions: {
show: {
maxThroughput: [undefined],
},
},
routing: {
send: {
type: 'query',
property: 'x-ms-offer-throughput',
value: '={{$value}}',
},
},
},
],
placeholder: 'Add Option',
type: 'collection',
@ -192,50 +187,6 @@ export const createFields: INodeProperties[] = [
];
export const getFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['container'],
operation: ['get'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'Container ID',
name: 'collId',
@ -282,98 +233,9 @@ export const getFields: INodeProperties[] = [
},
];
export const getAllFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['container'],
operation: ['getAll'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
];
export const getAllFields: INodeProperties[] = [];
export const deleteFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['container'],
operation: ['delete'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'Container ID',
name: 'collId',

View file

@ -22,7 +22,7 @@ export const itemOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'POST',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs',
url: '=/colls/{{ $parameter["collId"] }}/docs',
},
},
action: 'Create item',
@ -35,7 +35,7 @@ export const itemOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'DELETE',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
},
},
action: 'Delete item',
@ -45,10 +45,20 @@ export const itemOperations: INodeProperties[] = [
value: 'get',
description: 'Retrieve an item',
routing: {
send: {
preSend: [
async function (
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
return requestOptions;
},
],
},
request: {
ignoreHttpStatusErrors: true,
method: 'GET',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
},
},
action: 'Get item',
@ -67,7 +77,7 @@ export const itemOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'GET',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs',
url: '=/colls/{{ $parameter["collId"] }}/docs',
},
},
action: 'Get many items',
@ -80,7 +90,7 @@ export const itemOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'POST',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs',
url: '=/colls/{{ $parameter["collId"] }}/docs',
headers: {
'Content-Type': 'application/query+json',
'x-ms-documentdb-isquery': 'True',
@ -97,7 +107,7 @@ export const itemOperations: INodeProperties[] = [
request: {
ignoreHttpStatusErrors: true,
method: 'PATCH',
url: '=/dbs/{{ $parameter["dbId"] }}/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
url: '=/colls/{{ $parameter["collId"] }}/docs/{{ $parameter["id"] }}',
},
},
action: 'Update item',
@ -108,50 +118,6 @@ export const itemOperations: INodeProperties[] = [
];
export const createFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['create'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'Collection ID',
name: 'collId',
@ -196,28 +162,28 @@ export const createFields: INodeProperties[] = [
},
],
},
{
displayName: 'ID',
name: 'id',
type: 'string',
default: '',
placeholder: 'e.g. AndersenFamily',
description: "Item's ID",
required: true,
displayOptions: {
show: {
resource: ['item'],
operation: ['create'],
},
},
routing: {
send: {
type: 'body',
property: 'id',
value: '={{$value}}',
},
},
},
// {
// displayName: 'ID',
// name: 'id',
// type: 'string',
// default: '',
// placeholder: 'e.g. AndersenFamily',
// description: "Item's ID",
// required: true,
// displayOptions: {
// show: {
// resource: ['item'],
// operation: ['create'],
// },
// },
// routing: {
// send: {
// type: 'body',
// property: 'id',
// value: '={{$value}}',
// },
// },
// },
{
displayName: 'Custom Properties',
name: 'customProperties',
@ -235,58 +201,13 @@ export const createFields: INodeProperties[] = [
routing: {
send: {
type: 'body',
property: '',
value: '={{$value}}',
value: '={{ $json["id"] ? Object.assign({ id: $json["id"] }, $value) : $value }}',
},
},
},
];
export const deleteFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['delete'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'Collection ID',
name: 'collId',
@ -349,50 +270,6 @@ export const deleteFields: INodeProperties[] = [
];
export const getFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['get'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'Collection ID',
name: 'collId',
@ -451,61 +328,10 @@ export const getFields: INodeProperties[] = [
operation: ['get'],
},
},
routing: {
send: {
type: 'body',
property: 'id',
value: '={{$value}}',
},
},
},
];
export const getAllFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['getAll'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'Collection ID',
name: 'collId',
@ -561,18 +387,6 @@ export const getAllFields: INodeProperties[] = [
operation: ['getAll'],
},
},
routing: {
send: {
preSend: [
async function (
this: IExecuteSingleFunctions,
requestOptions: IHttpRequestOptions,
): Promise<IHttpRequestOptions> {
return requestOptions;
},
],
},
},
type: 'boolean',
},
{
@ -603,50 +417,6 @@ export const getAllFields: INodeProperties[] = [
];
export const queryFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['query'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'Collection ID',
name: 'collId',
@ -723,6 +493,12 @@ export const queryFields: INodeProperties[] = [
typeOptions: {
multipleValues: true,
},
displayOptions: {
show: {
resource: ['item'],
operation: ['query'],
},
},
options: [
{
name: 'parameters',
@ -756,50 +532,6 @@ export const queryFields: INodeProperties[] = [
];
export const updateFields: INodeProperties[] = [
{
displayName: 'Database ID',
name: 'dbId',
type: 'resourceLocator',
required: true,
default: {
mode: 'list',
value: '',
},
description: 'Select the database you want to use',
displayOptions: {
show: {
resource: ['item'],
operation: ['update'],
},
},
modes: [
{
displayName: 'From list',
name: 'list',
type: 'list',
typeOptions: {
searchListMethod: 'searchDatabases',
searchable: true,
},
},
{
displayName: 'By Name',
name: 'databaseName',
type: 'string',
hint: 'Enter the database name',
validation: [
{
type: 'regex',
properties: {
regex: '^[\\w+=,.@-]+$',
errorMessage: 'The database name must follow the allowed pattern.',
},
},
],
placeholder: 'e.g. UsersDB',
},
],
},
{
displayName: 'Collection ID',
name: 'collId',

View file

@ -0,0 +1,185 @@
import { azureCosmosDbRequest } from '../GenericFunctions';
describe('GenericFunctions - azureCosmosDbRequest', () => {
let mockContext: any;
let mockRequestWithAuthentication: jest.Mock;
beforeEach(() => {
mockRequestWithAuthentication = jest.fn();
mockContext = {
helpers: {
requestWithAuthentication: mockRequestWithAuthentication,
},
getCredentials: jest.fn(),
};
});
test('should make a successful request with correct options', async () => {
mockRequestWithAuthentication.mockResolvedValueOnce({ success: true });
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
const requestOptions = {
method: 'GET' as const,
url: '/example-endpoint',
headers: {
'Content-Type': 'application/json',
},
};
const result = await azureCosmosDbRequest.call(mockContext, requestOptions);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'azureCosmosDbSharedKeyApi',
expect.objectContaining({
method: 'GET',
baseURL: 'https://us-east-1.documents.azure.com',
url: '/example-endpoint',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
json: true,
}),
);
expect(result).toEqual({ success: true });
});
test('should throw an error if account is missing in credentials', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({});
const requestOptions = {
method: 'GET' as const,
url: '/example-endpoint',
headers: {
'Content-Type': 'application/json',
},
};
await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow(
'Database account not found in credentials!',
);
expect(mockRequestWithAuthentication).not.toHaveBeenCalled();
});
test('should throw a descriptive error for invalid credentials (403)', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
mockRequestWithAuthentication.mockRejectedValueOnce({
statusCode: 403,
response: {
body: {
message: 'The security token included in the request is invalid.',
},
},
});
const requestOptions = {
method: 'GET' as const,
url: '/example-endpoint',
headers: {
'Content-Type': 'application/json',
},
};
await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow(
'The Cosmos DB credentials are not valid!',
);
expect(mockRequestWithAuthentication).toHaveBeenCalled();
});
test('should throw a descriptive error for invalid request signature (403)', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
mockRequestWithAuthentication.mockRejectedValueOnce({
statusCode: 403,
response: {
body: {
message: 'The request signature we calculated does not match the signature you provided',
},
},
});
const requestOptions = {
method: 'GET' as const,
url: '/example-endpoint',
headers: {
'Content-Type': 'application/json',
},
};
await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow(
'The Cosmos DB credentials are not valid!',
);
expect(mockRequestWithAuthentication).toHaveBeenCalled();
});
test('should throw an error for resource not found (404)', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
mockRequestWithAuthentication.mockRejectedValueOnce({
statusCode: 404,
response: {
body: {
message: 'The specified resource does not exist.',
},
},
});
const requestOptions = {
method: 'GET' as const,
url: '/example-endpoint',
headers: {
'Content-Type': 'application/json',
},
};
await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow(
'The requested resource was not found!',
);
expect(mockRequestWithAuthentication).toHaveBeenCalled();
});
test('should throw a generic error for unexpected response', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
mockRequestWithAuthentication.mockRejectedValueOnce({
statusCode: 500,
message: 'Internal Server Error',
});
const requestOptions = {
method: 'GET' as const,
url: '/example-endpoint',
headers: {
'Content-Type': 'application/json',
},
};
await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow(
'Cosmos DB error response [500]: Internal Server Error',
);
expect(mockRequestWithAuthentication).toHaveBeenCalled();
});
test('should handle unexpected error structures', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
mockRequestWithAuthentication.mockRejectedValueOnce({
cause: { error: { message: 'Unexpected failure' } },
});
const requestOptions = {
method: 'GET' as const,
url: '/example-endpoint',
headers: {
'Content-Type': 'application/json',
},
};
await expect(azureCosmosDbRequest.call(mockContext, requestOptions)).rejects.toThrow(
'Cosmos DB error response [undefined]: Unexpected failure',
);
expect(mockRequestWithAuthentication).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,116 @@
import { handlePagination } from '../GenericFunctions';
describe('GenericFunctions - handlePagination', () => {
let mockContext: any;
let mockMakeRoutingRequest: jest.Mock;
let resultOptions: any;
beforeEach(() => {
mockMakeRoutingRequest = jest.fn();
mockContext = {
makeRoutingRequest: mockMakeRoutingRequest,
getNodeParameter: jest.fn(),
};
resultOptions = {
maxResults: 60,
options: { body: {} },
};
});
test('should aggregate results and handle pagination when returnAll is true', async () => {
mockMakeRoutingRequest
.mockResolvedValueOnce([
{ id: 1 },
{ id: 2 },
{ headers: { 'x-ms-continuation': 'token-1' } },
])
.mockResolvedValueOnce([{ id: 3 }, { id: 4 }, { headers: {} }]);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'returnAll') return true;
return undefined;
});
const result = await handlePagination.call(mockContext, resultOptions);
expect(result).toEqual([
{ json: { id: 1 } },
{ json: { id: 2 } },
{ json: { id: 3 } },
{ json: { id: 4 } },
]);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2);
expect(resultOptions.options.headers).toEqual({
'x-ms-continuation': 'token-1',
});
});
test('should stop pagination after reaching limit when returnAll is false', async () => {
mockMakeRoutingRequest
.mockResolvedValueOnce([
{ id: 1 },
{ id: 2 },
{ headers: { 'x-ms-continuation': 'token-1' } },
])
.mockResolvedValueOnce([{ id: 3 }, { id: 4 }, { headers: {} }]);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'returnAll') return false;
if (param === 'limit') return 3;
return undefined;
});
const result = await handlePagination.call(mockContext, resultOptions);
expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }, { json: { id: 3 } }]);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(2);
});
test('should handle cases with no continuation token gracefully', async () => {
mockMakeRoutingRequest.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'returnAll') return true;
return undefined;
});
const result = await handlePagination.call(mockContext, resultOptions);
expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }]);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1);
});
test('should respect the limit even if fewer results are available', async () => {
mockMakeRoutingRequest.mockResolvedValueOnce([{ id: 1 }, { id: 2 }]);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'returnAll') return false;
if (param === 'limit') return 5;
return undefined;
});
const result = await handlePagination.call(mockContext, resultOptions);
expect(result).toEqual([{ json: { id: 1 } }, { json: { id: 2 } }]);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1);
});
test('should break the loop if no results are returned', async () => {
mockMakeRoutingRequest.mockResolvedValueOnce([]);
mockContext.getNodeParameter.mockImplementation((param: string) => {
if (param === 'returnAll') return true;
return undefined;
});
const result = await handlePagination.call(mockContext, resultOptions);
expect(result).toEqual([]);
expect(mockMakeRoutingRequest).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,122 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchCollections } from '../GenericFunctions';
describe('GenericFunctions - searchCollections', () => {
const mockRequestWithAuthentication = jest.fn();
const mockContext = {
helpers: {
requestWithAuthentication: mockRequestWithAuthentication,
},
getNodeParameter: jest.fn(),
getCredentials: jest.fn(),
} as unknown as ILoadOptionsFunctions;
beforeEach(() => {
jest.clearAllMocks();
});
it('should make a GET request to fetch collections and return results', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
mockRequestWithAuthentication.mockResolvedValueOnce({
DocumentCollections: [{ id: 'Collection1' }, { id: 'Collection2' }],
});
const response = await searchCollections.call(mockContext);
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'azureCosmosDbSharedKeyApi',
expect.objectContaining({
baseURL: 'https://us-east-1.documents.azure.com',
method: 'GET',
url: '/colls',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
json: true,
}),
);
expect(response).toEqual({
results: [
{ name: 'Collection1', value: 'Collection1' },
{ name: 'Collection2', value: 'Collection2' },
],
});
});
it('should filter collections by the provided filter string', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
mockRequestWithAuthentication.mockResolvedValueOnce({
DocumentCollections: [{ id: 'Test-Col-1' }, { id: 'Prod-Col-1' }],
});
const response = await searchCollections.call(mockContext, 'Test');
expect(mockRequestWithAuthentication).toHaveBeenCalledWith(
'azureCosmosDbSharedKeyApi',
expect.objectContaining({
baseURL: 'https://us-east-1.documents.azure.com',
method: 'GET',
url: '/colls',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
json: true,
}),
);
expect(response).toEqual({
results: [{ name: 'Test-Col-1', value: 'Test-Col-1' }],
});
});
it('should sort collections alphabetically by name', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
(mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1');
mockRequestWithAuthentication.mockResolvedValueOnce({
DocumentCollections: [{ id: 'z-col' }, { id: 'a-col' }, { id: 'm-col' }],
});
const response = await searchCollections.call(mockContext);
expect(response).toEqual({
results: [
{ name: 'a-col', value: 'a-col' },
{ name: 'm-col', value: 'm-col' },
{ name: 'z-col', value: 'z-col' },
],
});
});
it('should handle empty results when no collections are returned', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
(mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1');
mockRequestWithAuthentication.mockResolvedValueOnce({
DocumentCollections: [],
});
const response = await searchCollections.call(mockContext);
expect(response).toEqual({ results: [] });
});
it('should handle missing Collections property', async () => {
(mockContext.getCredentials as jest.Mock).mockResolvedValueOnce({ account: 'us-east-1' });
(mockContext.getNodeParameter as jest.Mock).mockReturnValueOnce('db-id-1');
mockRequestWithAuthentication.mockResolvedValueOnce({
unexpectedkey: 'value',
});
const response = await searchCollections.call(mockContext);
expect(response).toEqual({ results: [] });
});
});