mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
feat(Beeminder Node): Update Beeminder node to include all resources and operations (#17713)
This commit is contained in:
@@ -13,12 +13,6 @@ export class BeeminderApi implements ICredentialType {
|
|||||||
documentationUrl = 'beeminder';
|
documentationUrl = 'beeminder';
|
||||||
|
|
||||||
properties: INodeProperties[] = [
|
properties: INodeProperties[] = [
|
||||||
{
|
|
||||||
displayName: 'User',
|
|
||||||
name: 'user',
|
|
||||||
type: 'string',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
displayName: 'Auth Token',
|
displayName: 'Auth Token',
|
||||||
name: 'authToken',
|
name: 'authToken',
|
||||||
@@ -40,7 +34,7 @@ export class BeeminderApi implements ICredentialType {
|
|||||||
test: ICredentialTestRequest = {
|
test: ICredentialTestRequest = {
|
||||||
request: {
|
request: {
|
||||||
baseURL: 'https://www.beeminder.com/api/v1',
|
baseURL: 'https://www.beeminder.com/api/v1',
|
||||||
url: '=/users/{{$credentials.user}}.json',
|
url: '/users/me.json',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class BeeminderOAuth2Api implements ICredentialType {
|
||||||
|
name = 'beeminderOAuth2Api';
|
||||||
|
|
||||||
|
extends = ['oAuth2Api'];
|
||||||
|
|
||||||
|
displayName = 'Beeminder OAuth2 API';
|
||||||
|
|
||||||
|
documentationUrl = 'beeminder';
|
||||||
|
|
||||||
|
properties: INodeProperties[] = [
|
||||||
|
{
|
||||||
|
displayName: 'Grant Type',
|
||||||
|
name: 'grantType',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'authorizationCode',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authorization URL',
|
||||||
|
name: 'authUrl',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'https://www.beeminder.com/apps/authorize',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Access Token URL',
|
||||||
|
name: 'accessTokenUrl',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'https://www.beeminder.com/apps/authorize',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Auth URI Query Parameters',
|
||||||
|
name: 'authQueryParameters',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'response_type=token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Authentication',
|
||||||
|
name: 'authentication',
|
||||||
|
type: 'hidden',
|
||||||
|
default: 'body',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Scope',
|
||||||
|
name: 'scope',
|
||||||
|
type: 'hidden',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,31 +1,40 @@
|
|||||||
import type {
|
import type {
|
||||||
IExecuteFunctions,
|
IExecuteFunctions,
|
||||||
ILoadOptionsFunctions,
|
ILoadOptionsFunctions,
|
||||||
IDataObject,
|
|
||||||
IHookFunctions,
|
IHookFunctions,
|
||||||
IWebhookFunctions,
|
IWebhookFunctions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { beeminderApiRequest, beeminderApiRequestAllItems } from './GenericFunctions';
|
import { beeminderApiRequest, beeminderApiRequestAllItems } from './GenericFunctions';
|
||||||
|
|
||||||
|
export interface Datapoint {
|
||||||
|
timestamp: number;
|
||||||
|
value: number;
|
||||||
|
comment?: string;
|
||||||
|
requestid?: string;
|
||||||
|
daystamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDatapoint(
|
export async function createDatapoint(
|
||||||
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
data: IDataObject,
|
data: {
|
||||||
|
goalName: string;
|
||||||
|
value: number;
|
||||||
|
timestamp?: number;
|
||||||
|
comment?: string;
|
||||||
|
requestid?: string;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const credentials = await this.getCredentials('beeminderApi');
|
const endpoint = `/users/me/goals/${data.goalName}/datapoints.json`;
|
||||||
|
|
||||||
const endpoint = `/users/${credentials.user}/goals/${data.goalName}/datapoints.json`;
|
return await beeminderApiRequest.call(this, 'POST', endpoint, data, {}, true);
|
||||||
|
|
||||||
return await beeminderApiRequest.call(this, 'POST', endpoint, data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllDatapoints(
|
export async function getAllDatapoints(
|
||||||
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
data: IDataObject,
|
data: { goalName: string; count?: number; sort?: string; page?: number; per?: number },
|
||||||
) {
|
) {
|
||||||
const credentials = await this.getCredentials('beeminderApi');
|
const endpoint = `/users/me/goals/${data.goalName}/datapoints.json`;
|
||||||
|
|
||||||
const endpoint = `/users/${credentials.user}/goals/${data.goalName}/datapoints.json`;
|
|
||||||
|
|
||||||
if (data.count !== undefined) {
|
if (data.count !== undefined) {
|
||||||
return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data);
|
return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data);
|
||||||
@@ -36,22 +45,194 @@ export async function getAllDatapoints(
|
|||||||
|
|
||||||
export async function updateDatapoint(
|
export async function updateDatapoint(
|
||||||
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
data: IDataObject,
|
data: {
|
||||||
|
goalName: string;
|
||||||
|
datapointId: string;
|
||||||
|
value?: number;
|
||||||
|
comment?: string;
|
||||||
|
timestamp?: number;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const credentials = await this.getCredentials('beeminderApi');
|
const endpoint = `/users/me/goals/${data.goalName}/datapoints/${data.datapointId}.json`;
|
||||||
|
|
||||||
const endpoint = `/users/${credentials.user}/goals/${data.goalName}/datapoints/${data.datapointId}.json`;
|
return await beeminderApiRequest.call(this, 'PUT', endpoint, data, {}, true);
|
||||||
|
|
||||||
return await beeminderApiRequest.call(this, 'PUT', endpoint, data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDatapoint(
|
export async function deleteDatapoint(
|
||||||
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
data: IDataObject,
|
data: { goalName: string; datapointId: string },
|
||||||
) {
|
) {
|
||||||
const credentials = await this.getCredentials('beeminderApi');
|
const endpoint = `/users/me/goals/${data.goalName}/datapoints/${data.datapointId}.json`;
|
||||||
|
|
||||||
const endpoint = `/users/${credentials.user}/goals/${data.goalName}/datapoints/${data.datapointId}.json`;
|
|
||||||
|
|
||||||
return await beeminderApiRequest.call(this, 'DELETE', endpoint);
|
return await beeminderApiRequest.call(this, 'DELETE', endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createCharge(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { amount: number; note?: string; dryrun?: boolean },
|
||||||
|
) {
|
||||||
|
const endpoint = '/charges.json';
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
user_id: 'me',
|
||||||
|
amount: data.amount,
|
||||||
|
...(data.note && { note: data.note }),
|
||||||
|
...(data.dryrun && { dryrun: data.dryrun }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'POST', endpoint, body, {}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uncleGoal(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { goalName: string },
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}/uncleme.json`;
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'POST', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAllDatapoints(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { goalName: string; datapoints: Datapoint[] },
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}/datapoints/create_all.json`;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
datapoints: data.datapoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'POST', endpoint, body, {}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSingleDatapoint(
|
||||||
|
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { goalName: string; datapointId: string },
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}/datapoints/${data.datapointId}.json`;
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'GET', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Goal Operations
|
||||||
|
export async function getGoal(
|
||||||
|
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { goalName: string; datapoints?: boolean; emaciated?: boolean },
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}.json`;
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllGoals(
|
||||||
|
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data?: { emaciated?: boolean },
|
||||||
|
) {
|
||||||
|
const endpoint = '/users/me/goals.json';
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArchivedGoals(
|
||||||
|
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data?: { emaciated?: boolean },
|
||||||
|
) {
|
||||||
|
const endpoint = '/users/me/goals/archived.json';
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGoal(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
goal_type: string;
|
||||||
|
gunits: string;
|
||||||
|
goaldate?: number;
|
||||||
|
goalval?: number;
|
||||||
|
rate?: number;
|
||||||
|
initval?: number;
|
||||||
|
secret?: boolean;
|
||||||
|
datapublic?: boolean;
|
||||||
|
datasource?: string;
|
||||||
|
dryrun?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const endpoint = '/users/me/goals.json';
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'POST', endpoint, data, {}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGoal(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: {
|
||||||
|
goalName: string;
|
||||||
|
title?: string;
|
||||||
|
yaxis?: string;
|
||||||
|
tmin?: string;
|
||||||
|
tmax?: string;
|
||||||
|
secret?: boolean;
|
||||||
|
datapublic?: boolean;
|
||||||
|
roadall?: object;
|
||||||
|
datasource?: string;
|
||||||
|
tags?: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}.json`;
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'PUT', endpoint, data, {}, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshGoal(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { goalName: string },
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}/refresh_graph.json`;
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'GET', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shortCircuitGoal(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { goalName: string },
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}/shortcircuit.json`;
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'POST', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stepDownGoal(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { goalName: string },
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}/stepdown.json`;
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'POST', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancelStepDownGoal(
|
||||||
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: { goalName: string },
|
||||||
|
) {
|
||||||
|
const endpoint = `/users/me/goals/${data.goalName}/cancel_stepdown.json`;
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'POST', endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Operations
|
||||||
|
export async function getUser(
|
||||||
|
this: IExecuteFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
|
data: {
|
||||||
|
associations?: boolean;
|
||||||
|
diff_since?: number;
|
||||||
|
skinny?: boolean;
|
||||||
|
emaciated?: boolean;
|
||||||
|
datapoints_count?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const endpoint = '/users/me.json';
|
||||||
|
|
||||||
|
return await beeminderApiRequest.call(this, 'GET', endpoint, {}, data);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,34 @@ import type {
|
|||||||
IHttpRequestMethods,
|
IHttpRequestMethods,
|
||||||
IRequestOptions,
|
IRequestOptions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
const BEEMINDER_URI = 'https://www.beeminder.com/api/v1';
|
const BEEMINDER_URI = 'https://www.beeminder.com/api/v1';
|
||||||
|
|
||||||
|
function isValidAuthenticationMethod(value: unknown): value is 'apiToken' | 'oAuth2' {
|
||||||
|
return typeof value === 'string' && ['apiToken', 'oAuth2'].includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToFormData(obj: any): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
// Skip null/undefined values
|
||||||
|
continue;
|
||||||
|
} else if (typeof value === 'boolean') {
|
||||||
|
result[key] = value.toString();
|
||||||
|
} else if (typeof value === 'number') {
|
||||||
|
result[key] = value.toString();
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
// Handle arrays - convert to JSON string for form data
|
||||||
|
result[key] = JSON.stringify(value);
|
||||||
|
} else {
|
||||||
|
result[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export async function beeminderApiRequest(
|
export async function beeminderApiRequest(
|
||||||
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions,
|
||||||
method: IHttpRequestMethods,
|
method: IHttpRequestMethods,
|
||||||
@@ -17,24 +42,45 @@ export async function beeminderApiRequest(
|
|||||||
|
|
||||||
body: any = {},
|
body: any = {},
|
||||||
query: IDataObject = {},
|
query: IDataObject = {},
|
||||||
|
useFormData: boolean = false,
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
|
const authenticationMethod = this.getNodeParameter('authentication', 0, 'apiToken');
|
||||||
|
|
||||||
|
if (!isValidAuthenticationMethod(authenticationMethod)) {
|
||||||
|
throw new ApplicationError(`Invalid authentication method: ${authenticationMethod}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentialType = 'beeminderApi';
|
||||||
|
if (authenticationMethod === 'oAuth2') {
|
||||||
|
credentialType = 'beeminderOAuth2Api';
|
||||||
|
}
|
||||||
|
|
||||||
const options: IRequestOptions = {
|
const options: IRequestOptions = {
|
||||||
method,
|
method,
|
||||||
body,
|
|
||||||
qs: query,
|
qs: query,
|
||||||
uri: `${BEEMINDER_URI}${endpoint}`,
|
uri: `${BEEMINDER_URI}${endpoint}`,
|
||||||
json: true,
|
json: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (useFormData) {
|
||||||
|
options.formData = convertToFormData(body);
|
||||||
|
} else {
|
||||||
|
options.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.keys(body as IDataObject).length) {
|
if (!Object.keys(body as IDataObject).length) {
|
||||||
|
if (useFormData) {
|
||||||
|
delete options.formData;
|
||||||
|
} else {
|
||||||
delete options.body;
|
delete options.body;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.keys(query).length) {
|
if (!Object.keys(query).length) {
|
||||||
delete options.qs;
|
delete options.qs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await this.helpers.requestWithAuthentication.call(this, 'beeminderApi', options);
|
return await this.helpers.requestWithAuthentication.call(this, credentialType, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function beeminderApiRequestAllItems(
|
export async function beeminderApiRequestAllItems(
|
||||||
|
|||||||
@@ -0,0 +1,669 @@
|
|||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDatapoint,
|
||||||
|
getAllDatapoints,
|
||||||
|
updateDatapoint,
|
||||||
|
deleteDatapoint,
|
||||||
|
createCharge,
|
||||||
|
uncleGoal,
|
||||||
|
createAllDatapoints,
|
||||||
|
getSingleDatapoint,
|
||||||
|
getGoal,
|
||||||
|
getAllGoals,
|
||||||
|
getArchivedGoals,
|
||||||
|
createGoal,
|
||||||
|
updateGoal,
|
||||||
|
refreshGoal,
|
||||||
|
shortCircuitGoal,
|
||||||
|
stepDownGoal,
|
||||||
|
cancelStepDownGoal,
|
||||||
|
getUser,
|
||||||
|
type Datapoint,
|
||||||
|
} from '../Beeminder.node.functions';
|
||||||
|
import * as GenericFunctions from '../GenericFunctions';
|
||||||
|
|
||||||
|
// Mock the GenericFunctions
|
||||||
|
jest.mock('../GenericFunctions');
|
||||||
|
const mockedGenericFunctions = jest.mocked(GenericFunctions);
|
||||||
|
|
||||||
|
describe('Beeminder Node Functions', () => {
|
||||||
|
let mockContext: IExecuteFunctions;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockContext = mock<IExecuteFunctions>();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Datapoint Operations', () => {
|
||||||
|
describe('createDatapoint', () => {
|
||||||
|
it('should create a datapoint with required parameters', async () => {
|
||||||
|
const mockResponse = { id: '123', value: 10, timestamp: 1234567890 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
value: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createDatapoint.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals/testgoal/datapoints.json',
|
||||||
|
data,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a datapoint with all optional parameters', async () => {
|
||||||
|
const mockResponse = { id: '123', value: 10, timestamp: 1234567890 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
value: 10,
|
||||||
|
timestamp: 1234567890,
|
||||||
|
comment: 'Test comment',
|
||||||
|
requestid: 'req123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createDatapoint.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals/testgoal/datapoints.json',
|
||||||
|
data,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllDatapoints', () => {
|
||||||
|
it('should get all datapoints when count is not specified', async () => {
|
||||||
|
const mockResponse = [{ id: '1' }, { id: '2' }];
|
||||||
|
mockedGenericFunctions.beeminderApiRequestAllItems.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { goalName: 'testgoal' };
|
||||||
|
|
||||||
|
const result = await getAllDatapoints.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequestAllItems).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/testgoal/datapoints.json',
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get limited datapoints when count is specified', async () => {
|
||||||
|
const mockResponse = [{ id: '1' }];
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { goalName: 'testgoal', count: 1 };
|
||||||
|
|
||||||
|
const result = await getAllDatapoints.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/testgoal/datapoints.json',
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle optional parameters', async () => {
|
||||||
|
const mockResponse = [{ id: '1' }];
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
count: 5,
|
||||||
|
sort: 'id',
|
||||||
|
page: 2,
|
||||||
|
per: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getAllDatapoints.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/testgoal/datapoints.json',
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateDatapoint', () => {
|
||||||
|
it('should update a datapoint with required parameters', async () => {
|
||||||
|
const mockResponse = { id: '123', value: 15 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
datapointId: '123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateDatapoint.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'PUT',
|
||||||
|
'/users/me/goals/testgoal/datapoints/123.json',
|
||||||
|
data,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update a datapoint with all optional parameters', async () => {
|
||||||
|
const mockResponse = { id: '123', value: 15 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
datapointId: '123',
|
||||||
|
value: 15,
|
||||||
|
comment: 'Updated comment',
|
||||||
|
timestamp: 1234567890,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateDatapoint.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'PUT',
|
||||||
|
'/users/me/goals/testgoal/datapoints/123.json',
|
||||||
|
data,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteDatapoint', () => {
|
||||||
|
it('should delete a datapoint', async () => {
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
datapointId: '123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await deleteDatapoint.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'DELETE',
|
||||||
|
'/users/me/goals/testgoal/datapoints/123.json',
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAllDatapoints', () => {
|
||||||
|
it('should create multiple datapoints', async () => {
|
||||||
|
const mockResponse = { created: 2 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const datapoints: Datapoint[] = [
|
||||||
|
{ timestamp: 1234567890, value: 10, comment: 'First' },
|
||||||
|
{ timestamp: 1234567891, value: 20, comment: 'Second' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
datapoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createAllDatapoints.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals/testgoal/datapoints/create_all.json',
|
||||||
|
{ datapoints },
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSingleDatapoint', () => {
|
||||||
|
it('should get a single datapoint', async () => {
|
||||||
|
const mockResponse = { id: '123', value: 10 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
datapointId: '123',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getSingleDatapoint.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/testgoal/datapoints/123.json',
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Goal Operations', () => {
|
||||||
|
describe('getGoal', () => {
|
||||||
|
it('should get a goal with basic parameters', async () => {
|
||||||
|
const mockResponse = { slug: 'testgoal', title: 'Test Goal' };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { goalName: 'testgoal' };
|
||||||
|
|
||||||
|
const result = await getGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/testgoal.json',
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a goal with optional parameters', async () => {
|
||||||
|
const mockResponse = { slug: 'testgoal', title: 'Test Goal' };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
datapoints: true,
|
||||||
|
emaciated: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/testgoal.json',
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllGoals', () => {
|
||||||
|
it('should get all goals without parameters', async () => {
|
||||||
|
const mockResponse = [{ slug: 'goal1' }, { slug: 'goal2' }];
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await getAllGoals.call(mockContext);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals.json',
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get all goals with emaciated parameter', async () => {
|
||||||
|
const mockResponse = [{ slug: 'goal1' }, { slug: 'goal2' }];
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { emaciated: true };
|
||||||
|
|
||||||
|
const result = await getAllGoals.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals.json',
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getArchivedGoals', () => {
|
||||||
|
it('should get archived goals without parameters', async () => {
|
||||||
|
const mockResponse = [{ slug: 'archived1' }];
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const result = await getArchivedGoals.call(mockContext);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/archived.json',
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get archived goals with emaciated parameter', async () => {
|
||||||
|
const mockResponse = [{ slug: 'archived1' }];
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { emaciated: true };
|
||||||
|
|
||||||
|
const result = await getArchivedGoals.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/archived.json',
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGoal', () => {
|
||||||
|
it('should create a goal with required parameters', async () => {
|
||||||
|
const mockResponse = { slug: 'newgoal', id: 'goal123' };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
slug: 'newgoal',
|
||||||
|
title: 'New Goal',
|
||||||
|
goal_type: 'hustler',
|
||||||
|
gunits: 'hours',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals.json',
|
||||||
|
data,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a goal with all optional parameters', async () => {
|
||||||
|
const mockResponse = { slug: 'newgoal', id: 'goal123' };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
slug: 'newgoal',
|
||||||
|
title: 'New Goal',
|
||||||
|
goal_type: 'hustler',
|
||||||
|
gunits: 'hours',
|
||||||
|
goaldate: 1234567890,
|
||||||
|
goalval: 100,
|
||||||
|
rate: 1,
|
||||||
|
initval: 0,
|
||||||
|
secret: false,
|
||||||
|
datapublic: true,
|
||||||
|
datasource: 'manual',
|
||||||
|
dryrun: false,
|
||||||
|
tags: ['productivity', 'work'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals.json',
|
||||||
|
data,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateGoal', () => {
|
||||||
|
it('should update a goal with goalName', async () => {
|
||||||
|
const mockResponse = { slug: 'testgoal', title: 'Updated Title' };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
title: 'Updated Title',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'PUT',
|
||||||
|
'/users/me/goals/testgoal.json',
|
||||||
|
data,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update a goal with all optional parameters', async () => {
|
||||||
|
const mockResponse = { slug: 'testgoal' };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
goalName: 'testgoal',
|
||||||
|
title: 'Updated Title',
|
||||||
|
yaxis: 'Hours worked',
|
||||||
|
tmin: '08:00',
|
||||||
|
tmax: '18:00',
|
||||||
|
secret: true,
|
||||||
|
datapublic: false,
|
||||||
|
roadall: { rate: 2 },
|
||||||
|
datasource: 'api',
|
||||||
|
tags: ['work', 'productivity'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'PUT',
|
||||||
|
'/users/me/goals/testgoal.json',
|
||||||
|
data,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshGoal', () => {
|
||||||
|
it('should refresh a goal', async () => {
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { goalName: 'testgoal' };
|
||||||
|
|
||||||
|
const result = await refreshGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me/goals/testgoal/refresh_graph.json',
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shortCircuitGoal', () => {
|
||||||
|
it('should short circuit a goal', async () => {
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { goalName: 'testgoal' };
|
||||||
|
|
||||||
|
const result = await shortCircuitGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals/testgoal/shortcircuit.json',
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stepDownGoal', () => {
|
||||||
|
it('should step down a goal', async () => {
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { goalName: 'testgoal' };
|
||||||
|
|
||||||
|
const result = await stepDownGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals/testgoal/stepdown.json',
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancelStepDownGoal', () => {
|
||||||
|
it('should cancel step down for a goal', async () => {
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { goalName: 'testgoal' };
|
||||||
|
|
||||||
|
const result = await cancelStepDownGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals/testgoal/cancel_stepdown.json',
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Charge Operations', () => {
|
||||||
|
describe('createCharge', () => {
|
||||||
|
it('should create a charge with required amount', async () => {
|
||||||
|
const mockResponse = { id: 'charge123', amount: 5 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { amount: 5 };
|
||||||
|
|
||||||
|
const result = await createCharge.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/charges.json',
|
||||||
|
{
|
||||||
|
user_id: 'me',
|
||||||
|
amount: 5,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a charge with all optional parameters', async () => {
|
||||||
|
const mockResponse = { id: 'charge123', amount: 10 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
amount: 10,
|
||||||
|
note: 'Penalty charge',
|
||||||
|
dryrun: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createCharge.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/charges.json',
|
||||||
|
{
|
||||||
|
user_id: 'me',
|
||||||
|
amount: 10,
|
||||||
|
note: 'Penalty charge',
|
||||||
|
dryrun: true,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include undefined optional parameters', async () => {
|
||||||
|
const mockResponse = { id: 'charge123', amount: 5 };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
amount: 5,
|
||||||
|
note: undefined,
|
||||||
|
dryrun: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await createCharge.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/charges.json',
|
||||||
|
{
|
||||||
|
user_id: 'me',
|
||||||
|
amount: 5,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('uncleGoal', () => {
|
||||||
|
it('should uncle a goal', async () => {
|
||||||
|
const mockResponse = { success: true };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = { goalName: 'testgoal' };
|
||||||
|
|
||||||
|
const result = await uncleGoal.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/users/me/goals/testgoal/uncleme.json',
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Operations', () => {
|
||||||
|
describe('getUser', () => {
|
||||||
|
it('should get user information', async () => {
|
||||||
|
const mockResponse = { username: 'testuser', goals: [] };
|
||||||
|
mockedGenericFunctions.beeminderApiRequest.mockResolvedValue(mockResponse);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
associations: true,
|
||||||
|
diff_since: 1234567890,
|
||||||
|
skinny: false,
|
||||||
|
emaciated: false,
|
||||||
|
datapoints_count: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getUser.call(mockContext, data);
|
||||||
|
|
||||||
|
expect(mockedGenericFunctions.beeminderApiRequest).toHaveBeenCalledWith(
|
||||||
|
'GET',
|
||||||
|
'/users/me.json',
|
||||||
|
{},
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
expect(result).toBe(mockResponse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
124
packages/nodes-base/nodes/Beeminder/test/Beeminder.node.test.ts
Normal file
124
packages/nodes-base/nodes/Beeminder/test/Beeminder.node.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||||
|
import type { WorkflowTestData } from 'n8n-workflow';
|
||||||
|
import nock from 'nock';
|
||||||
|
|
||||||
|
const userInfo = {
|
||||||
|
username: 'test',
|
||||||
|
timezone: 'Europe/Zurich',
|
||||||
|
goals: ['test3333', 'test333'],
|
||||||
|
created_at: 1520089425,
|
||||||
|
updated_at: 1753992556,
|
||||||
|
urgency_load: 43,
|
||||||
|
deadbeat: false,
|
||||||
|
has_authorized_fitbit: false,
|
||||||
|
default_leadtime: 0,
|
||||||
|
default_alertstart: 34200,
|
||||||
|
default_deadline: -43260,
|
||||||
|
subscription: 'infinibee',
|
||||||
|
subs_downto: 'infinibee',
|
||||||
|
subs_freq: 24,
|
||||||
|
subs_lifetime: null,
|
||||||
|
remaining_subs_credit: 31,
|
||||||
|
id: '35555555555',
|
||||||
|
};
|
||||||
|
|
||||||
|
const chargeInfo = {
|
||||||
|
amount: 10,
|
||||||
|
id: {
|
||||||
|
$oid: '688bcd7cf0168a11bee246ffff',
|
||||||
|
},
|
||||||
|
note: 'Created by test-test-oauth2',
|
||||||
|
status: null,
|
||||||
|
username: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const goalInfo = {
|
||||||
|
slug: 'test3333',
|
||||||
|
title: 'test title',
|
||||||
|
description: null,
|
||||||
|
goalval: 1000,
|
||||||
|
rate: 1,
|
||||||
|
rah: null,
|
||||||
|
callback_url: null,
|
||||||
|
tags: [],
|
||||||
|
recent_data: [
|
||||||
|
{
|
||||||
|
id: '688bc5fef0168a11bee24691',
|
||||||
|
timestamp: 1754042339,
|
||||||
|
daystamp: '20250801',
|
||||||
|
value: 0,
|
||||||
|
comment: 'initial datapoint of 0 on the 1st',
|
||||||
|
updated_at: 1753990654,
|
||||||
|
requestid: null,
|
||||||
|
origin: 'nihilo',
|
||||||
|
creator: '',
|
||||||
|
is_dummy: false,
|
||||||
|
is_initial: true,
|
||||||
|
urtext: null,
|
||||||
|
fulltext: '2025-Aug-01 entered at 21:37 on 2025-Jul-31 ex nihilo',
|
||||||
|
canonical: '01 0 "initial datapoint of 0 on the 1st"',
|
||||||
|
created_at: '2025-07-31T19:37:34.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dueby: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newDatapoint = {
|
||||||
|
id: '688bc54ef0168a11bee2468b',
|
||||||
|
timestamp: 1753990478,
|
||||||
|
daystamp: '20250801',
|
||||||
|
value: 1,
|
||||||
|
comment: '',
|
||||||
|
updated_at: 1753990478,
|
||||||
|
requestid: null,
|
||||||
|
origin: 'test-test-oauth2',
|
||||||
|
creator: 'testuser',
|
||||||
|
is_dummy: false,
|
||||||
|
is_initial: false,
|
||||||
|
urtext: null,
|
||||||
|
fulltext: '2025-Aug-01 entered at 21:34 on 2025-Jul-31 by test-ser via test-test-oauth2',
|
||||||
|
canonical: '01 1',
|
||||||
|
created_at: '2025-07-31T19:34:38.000Z',
|
||||||
|
status: 'created',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Execute Beeminder Node', () => {
|
||||||
|
const testHarness = new NodeTestHarness();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const beeminderNock = nock('https://www.beeminder.com');
|
||||||
|
beeminderNock.get('/api/v1/users/me.json').reply(200, userInfo);
|
||||||
|
beeminderNock.post('/api/v1/charges.json').reply(200, chargeInfo);
|
||||||
|
beeminderNock.get('/api/v1/users/me/goals.json').reply(200, [goalInfo]);
|
||||||
|
beeminderNock.post('/api/v1/users/me/goals.json').reply(200, goalInfo);
|
||||||
|
beeminderNock
|
||||||
|
.post(`/api/v1/users/me/goals/${goalInfo.slug}/datapoints.json`)
|
||||||
|
.reply(200, newDatapoint);
|
||||||
|
beeminderNock
|
||||||
|
.put(`/api/v1/users/me/goals/${goalInfo.slug}/datapoints/${newDatapoint.id}.json`)
|
||||||
|
.reply(200, newDatapoint);
|
||||||
|
beeminderNock
|
||||||
|
.delete(`/api/v1/users/me/goals/${goalInfo.slug}/datapoints/${newDatapoint.id}.json`)
|
||||||
|
.reply(200, newDatapoint);
|
||||||
|
});
|
||||||
|
|
||||||
|
const testData: WorkflowTestData = {
|
||||||
|
description: 'Execute operations',
|
||||||
|
input: {
|
||||||
|
workflowData: testHarness.readWorkflowJSON('workflow.json'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
nodeData: {
|
||||||
|
'Get user information': [[{ json: userInfo }]],
|
||||||
|
'Create a charge': [[{ json: chargeInfo }]],
|
||||||
|
'Get many goals': [[{ json: goalInfo }]],
|
||||||
|
'Create a new goal': [[{ json: goalInfo }]],
|
||||||
|
'Create datapoint for goal': [[{ json: newDatapoint }]],
|
||||||
|
'Update a datapoint': [[{ json: newDatapoint }]],
|
||||||
|
'Delete a datapoint': [[{ json: newDatapoint }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
testHarness.setupTest(testData, { credentials: { beeminderApi: {} } });
|
||||||
|
});
|
||||||
238
packages/nodes-base/nodes/Beeminder/test/workflow.json
Normal file
238
packages/nodes-base/nodes/Beeminder/test/workflow.json
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {},
|
||||||
|
"type": "n8n-nodes-base.manualTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [-848, -400],
|
||||||
|
"id": "852d5569-78ec-4d61-8b9c-8bdbe963fe6e",
|
||||||
|
"name": "When clicking ‘Execute workflow’"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "oAuth2",
|
||||||
|
"resource": "goal",
|
||||||
|
"operation": "create",
|
||||||
|
"slug": "test333",
|
||||||
|
"title": "test title",
|
||||||
|
"gunits": "unit",
|
||||||
|
"additionalFields": {
|
||||||
|
"goalval": 1000,
|
||||||
|
"rate": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.beeminder",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [48, -400],
|
||||||
|
"id": "0fbc833e-f4fb-430e-a130-9fd6655a35f1",
|
||||||
|
"name": "Create a new goal",
|
||||||
|
"credentials": {
|
||||||
|
"beeminderOAuth2Api": {
|
||||||
|
"id": "tXjFNZhKeJFjFOl7",
|
||||||
|
"name": "Beeminder account 3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "oAuth2",
|
||||||
|
"goalName": "={{ $json.slug }}",
|
||||||
|
"additionalFields": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.beeminder",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [272, -400],
|
||||||
|
"id": "b143d8f4-399c-47e9-9497-002497d0a2b3",
|
||||||
|
"name": "Create datapoint for goal",
|
||||||
|
"credentials": {
|
||||||
|
"beeminderOAuth2Api": {
|
||||||
|
"id": "tXjFNZhKeJFjFOl7",
|
||||||
|
"name": "Beeminder account 3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "oAuth2",
|
||||||
|
"operation": "delete",
|
||||||
|
"goalName": "={{ $('Create a new goal').item.json.slug }}",
|
||||||
|
"datapointId": "={{ $json.id }}"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.beeminder",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [720, -400],
|
||||||
|
"id": "69ef60fd-c66b-4cba-aaf6-96e4fd453ac2",
|
||||||
|
"name": "Delete a datapoint",
|
||||||
|
"credentials": {
|
||||||
|
"beeminderOAuth2Api": {
|
||||||
|
"id": "tXjFNZhKeJFjFOl7",
|
||||||
|
"name": "Beeminder account 3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "oAuth2",
|
||||||
|
"resource": "goal",
|
||||||
|
"operation": "getAll",
|
||||||
|
"additionalFields": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.beeminder",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [-176, -400],
|
||||||
|
"id": "1bdce766-3434-4e43-81bb-80a313ef4f2d",
|
||||||
|
"name": "Get many goals",
|
||||||
|
"credentials": {
|
||||||
|
"beeminderOAuth2Api": {
|
||||||
|
"id": "tXjFNZhKeJFjFOl7",
|
||||||
|
"name": "Beeminder account 3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "oAuth2",
|
||||||
|
"resource": "user",
|
||||||
|
"additionalFields": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.beeminder",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [-624, -400],
|
||||||
|
"id": "25bb239d-d990-4b82-9416-a282cc00f467",
|
||||||
|
"name": "Get user information",
|
||||||
|
"credentials": {
|
||||||
|
"beeminderOAuth2Api": {
|
||||||
|
"id": "tXjFNZhKeJFjFOl7",
|
||||||
|
"name": "Beeminder account 3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "oAuth2",
|
||||||
|
"resource": "charge",
|
||||||
|
"amount": 10,
|
||||||
|
"additionalFields": {
|
||||||
|
"dryrun": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.beeminder",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [-400, -400],
|
||||||
|
"id": "2fa9ea2e-e4e7-4291-bb6d-75a6bc15bc4e",
|
||||||
|
"name": "Create a charge",
|
||||||
|
"credentials": {
|
||||||
|
"beeminderOAuth2Api": {
|
||||||
|
"id": "tXjFNZhKeJFjFOl7",
|
||||||
|
"name": "Beeminder account 3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"authentication": "oAuth2",
|
||||||
|
"operation": "update",
|
||||||
|
"goalName": "={{ $('Create a new goal').item.json.slug }}",
|
||||||
|
"datapointId": "={{ $json.id }}",
|
||||||
|
"updateFields": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.beeminder",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [496, -400],
|
||||||
|
"id": "55d4490f-97a2-405b-8745-421291d0c82e",
|
||||||
|
"name": "Update a datapoint",
|
||||||
|
"credentials": {
|
||||||
|
"beeminderOAuth2Api": {
|
||||||
|
"id": "tXjFNZhKeJFjFOl7",
|
||||||
|
"name": "Beeminder account 3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"When clicking ‘Execute workflow’": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get user information",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Create a new goal": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Create datapoint for goal",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Create datapoint for goal": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Update a datapoint",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get many goals": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Create a new goal",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Get user information": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Create a charge",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Create a charge": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Get many goals",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Update a datapoint": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Delete a datapoint",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {
|
||||||
|
"When clicking ‘Execute workflow’": [{}]
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"templateCredsSetupCompleted": true,
|
||||||
|
"instanceId": "f0e9801eba0feea6a9ddf9beeabe34b0843eae42a1dbc62eaadd68e8f576be64"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
"dist/credentials/BannerbearApi.credentials.js",
|
"dist/credentials/BannerbearApi.credentials.js",
|
||||||
"dist/credentials/BaserowApi.credentials.js",
|
"dist/credentials/BaserowApi.credentials.js",
|
||||||
"dist/credentials/BeeminderApi.credentials.js",
|
"dist/credentials/BeeminderApi.credentials.js",
|
||||||
|
"dist/credentials/BeeminderOAuth2Api.credentials.js",
|
||||||
"dist/credentials/BitbucketApi.credentials.js",
|
"dist/credentials/BitbucketApi.credentials.js",
|
||||||
"dist/credentials/BitlyApi.credentials.js",
|
"dist/credentials/BitlyApi.credentials.js",
|
||||||
"dist/credentials/BitlyOAuth2Api.credentials.js",
|
"dist/credentials/BitlyOAuth2Api.credentials.js",
|
||||||
|
|||||||
94
packages/nodes-base/utils/types.ts
Normal file
94
packages/nodes-base/utils/types.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { assert } from 'n8n-workflow';
|
||||||
|
|
||||||
|
function assertIsType<T>(
|
||||||
|
parameterName: string,
|
||||||
|
value: unknown,
|
||||||
|
type: 'string' | 'number' | 'boolean',
|
||||||
|
): asserts value is T {
|
||||||
|
assert(typeof value === type, `Parameter "${parameterName}" is not ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertIsNumber(parameterName: string, value: unknown): asserts value is number {
|
||||||
|
assertIsType<number>(parameterName, value, 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertIsString(parameterName: string, value: unknown): asserts value is string {
|
||||||
|
assertIsType<string>(parameterName, value, 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertIsArray<T>(
|
||||||
|
parameterName: string,
|
||||||
|
value: unknown,
|
||||||
|
validator: (val: unknown) => val is T,
|
||||||
|
): asserts value is T[] {
|
||||||
|
assert(Array.isArray(value), `Parameter "${parameterName}" is not an array`);
|
||||||
|
assert(
|
||||||
|
value.every(validator),
|
||||||
|
`Parameter "${parameterName}" has elements that don't match expected types`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertIsNodeParameters<T>(
|
||||||
|
value: unknown,
|
||||||
|
parameters: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
type:
|
||||||
|
| 'string'
|
||||||
|
| 'boolean'
|
||||||
|
| 'number'
|
||||||
|
| 'resource-locator'
|
||||||
|
| 'string[]'
|
||||||
|
| 'number[]'
|
||||||
|
| 'boolean[]'
|
||||||
|
| 'object';
|
||||||
|
optional?: boolean;
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
): asserts value is T {
|
||||||
|
assert(typeof value === 'object' && value !== null, 'Value is not a valid object');
|
||||||
|
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
Object.keys(parameters).forEach((key) => {
|
||||||
|
const param = parameters[key];
|
||||||
|
const paramValue = obj[key];
|
||||||
|
|
||||||
|
if (!param.optional && paramValue === undefined) {
|
||||||
|
assert(false, `Required parameter "${key}" is missing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paramValue !== undefined) {
|
||||||
|
if (param.type === 'resource-locator') {
|
||||||
|
assert(
|
||||||
|
typeof paramValue === 'object' &&
|
||||||
|
paramValue !== null &&
|
||||||
|
'__rl' in paramValue &&
|
||||||
|
'mode' in paramValue &&
|
||||||
|
'value' in paramValue,
|
||||||
|
`Parameter "${key}" is not a valid resource locator object`,
|
||||||
|
);
|
||||||
|
} else if (param.type === 'object') {
|
||||||
|
assert(
|
||||||
|
typeof paramValue === 'object' && paramValue !== null,
|
||||||
|
`Parameter "${key}" is not a valid object`,
|
||||||
|
);
|
||||||
|
} else if (param.type.endsWith('[]')) {
|
||||||
|
const baseType = param.type.slice(0, -2);
|
||||||
|
const elementType =
|
||||||
|
baseType === 'string' || baseType === 'number' || baseType === 'boolean'
|
||||||
|
? baseType
|
||||||
|
: 'string';
|
||||||
|
assert(Array.isArray(paramValue), `Parameter "${key}" is not an array`);
|
||||||
|
paramValue.forEach((item, index) => {
|
||||||
|
assert(
|
||||||
|
typeof item === elementType,
|
||||||
|
`Parameter "${key}[${index}]" is not a valid ${elementType}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
assert(typeof paramValue === param.type, `Parameter "${key}" is not a valid ${param.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user