mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
fix(Pipedrive Node): Format date-only fields as YYYY-MM-DD (#30891)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Dawid Myslak <dawid.myslak@gmail.com>
This commit is contained in:
parent
1864916665
commit
ca74a8367d
|
|
@ -2,7 +2,12 @@ import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
|
|||
|
||||
import { encodeCustomFieldsV2, resolveCustomFieldsV2 } from '../../v2/helpers/customFields';
|
||||
import { addFieldsToBody } from '../../v2/helpers/fields';
|
||||
import { coerceToBoolean, coerceToNumber, toRfc3339 } from '../../v2/helpers/typeCoercion';
|
||||
import {
|
||||
coerceToBoolean,
|
||||
coerceToNumber,
|
||||
toDateOnly,
|
||||
toRfc3339,
|
||||
} from '../../v2/helpers/typeCoercion';
|
||||
import type { ICustomProperties } from '../../v2/transport';
|
||||
|
||||
const customProperties: ICustomProperties = {
|
||||
|
|
@ -388,4 +393,30 @@ describe('Pipedrive v2 Type Coercion', () => {
|
|||
expect(toRfc3339('2024-01-15')).toBe('2024-01-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDateOnly', () => {
|
||||
it('should pass through YYYY-MM-DD unchanged', () => {
|
||||
expect(toDateOnly('2024-01-15')).toBe('2024-01-15');
|
||||
});
|
||||
it('should strip the time component from an ISO datetime', () => {
|
||||
expect(toDateOnly('2024-01-15T00:00:00.000Z')).toBe('2024-01-15');
|
||||
expect(toDateOnly('2024-01-15T14:30:00Z')).toBe('2024-01-15');
|
||||
});
|
||||
it('should strip the time component from a space-separated datetime', () => {
|
||||
expect(toDateOnly('2024-01-15 14:30:00')).toBe('2024-01-15');
|
||||
});
|
||||
it('should return an empty input unchanged', () => {
|
||||
expect(toDateOnly('')).toBe('');
|
||||
});
|
||||
it('should return the original value when it cannot be parsed', () => {
|
||||
expect(toDateOnly('not-a-date')).toBe('not-a-date');
|
||||
});
|
||||
it('should return non-standard inputs verbatim instead of shifting the calendar day through UTC', () => {
|
||||
// Parsing 'Jan 15, 2024' as a local-time Date and converting via
|
||||
// toISOString() can yield 2024-01-14 in timezones east of UTC.
|
||||
// We deliberately avoid that conversion, so the input is passed
|
||||
// through and Pipedrive surfaces the real validation error.
|
||||
expect(toDateOnly('Jan 15, 2024')).toBe('Jan 15, 2024');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import type { IDataObject, IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import { execute as activityCreateExecute } from '../../v2/actions/activity/create.operation';
|
||||
import { execute as activityUpdateExecute } from '../../v2/actions/activity/update.operation';
|
||||
import { execute as dealCreateExecute } from '../../v2/actions/deal/create.operation';
|
||||
import { execute as dealUpdateExecute } from '../../v2/actions/deal/update.operation';
|
||||
import { pipedriveApiRequest, pipedriveGetCustomProperties } from '../../v2/transport';
|
||||
|
||||
jest.mock('../../v2/transport', () => ({
|
||||
pipedriveApiRequest: { call: jest.fn() },
|
||||
pipedriveApiRequestAllItemsCursor: { call: jest.fn() },
|
||||
pipedriveApiRequestAllItemsOffset: { call: jest.fn() },
|
||||
pipedriveGetCustomProperties: { call: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockApiRequest = pipedriveApiRequest as unknown as { call: jest.Mock };
|
||||
const mockGetCustomProperties = pipedriveGetCustomProperties as unknown as { call: jest.Mock };
|
||||
|
||||
function buildContext(params: Record<string, unknown>): IExecuteFunctions {
|
||||
return {
|
||||
getInputData: jest.fn(() => [{ json: {} }]),
|
||||
getNodeParameter: jest.fn((name: string, _i?: number, defaultValue?: unknown) => {
|
||||
if (Object.prototype.hasOwnProperty.call(params, name)) return params[name];
|
||||
return defaultValue;
|
||||
}),
|
||||
continueOnFail: jest.fn(() => false),
|
||||
helpers: {
|
||||
returnJsonArray: jest.fn((data: unknown) => (Array.isArray(data) ? data : [data])),
|
||||
constructExecutionMetaData: jest.fn((items: unknown) => items),
|
||||
},
|
||||
getNode: jest.fn(() => ({})),
|
||||
} as unknown as IExecuteFunctions;
|
||||
}
|
||||
|
||||
describe('Pipedrive v2 normalises ISO date inputs to YYYY-MM-DD on outgoing requests', () => {
|
||||
beforeEach(() => {
|
||||
mockApiRequest.call.mockReset().mockResolvedValue({ data: {}, additionalData: {} });
|
||||
mockGetCustomProperties.call.mockReset().mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('deal/create strips the time component from expected_close_date', async () => {
|
||||
const ctx = buildContext({
|
||||
rawCustomFieldKeys: true,
|
||||
title: 'Test Deal',
|
||||
associateWith: 'organization',
|
||||
org_id: 7,
|
||||
additionalFields: { expected_close_date: '2026-04-13T00:00:00.000Z' },
|
||||
});
|
||||
|
||||
await dealCreateExecute.call(ctx);
|
||||
|
||||
const [, method, endpoint, body] = mockApiRequest.call.mock.calls[0] as [
|
||||
unknown,
|
||||
string,
|
||||
string,
|
||||
IDataObject,
|
||||
];
|
||||
expect(method).toBe('POST');
|
||||
expect(endpoint).toBe('/deals');
|
||||
expect(body.expected_close_date).toBe('2026-04-13');
|
||||
});
|
||||
|
||||
it('deal/update strips the time component from expected_close_date', async () => {
|
||||
const ctx = buildContext({
|
||||
rawCustomFieldKeys: true,
|
||||
dealId: 10,
|
||||
updateFields: { expected_close_date: '2026-04-13T00:00:00.000Z' },
|
||||
});
|
||||
|
||||
await dealUpdateExecute.call(ctx);
|
||||
|
||||
const [, method, endpoint, body] = mockApiRequest.call.mock.calls[0] as [
|
||||
unknown,
|
||||
string,
|
||||
string,
|
||||
IDataObject,
|
||||
];
|
||||
expect(method).toBe('PATCH');
|
||||
expect(endpoint).toBe('/deals/10');
|
||||
expect(body.expected_close_date).toBe('2026-04-13');
|
||||
});
|
||||
|
||||
it('activity/create strips the time component from due_date', async () => {
|
||||
const ctx = buildContext({
|
||||
rawCustomFieldKeys: true,
|
||||
subject: 'Call client',
|
||||
done: false,
|
||||
type: 'call',
|
||||
additionalFields: { due_date: '2026-04-01T00:00:00.000Z' },
|
||||
});
|
||||
|
||||
await activityCreateExecute.call(ctx);
|
||||
|
||||
const [, method, endpoint, body] = mockApiRequest.call.mock.calls[0] as [
|
||||
unknown,
|
||||
string,
|
||||
string,
|
||||
IDataObject,
|
||||
];
|
||||
expect(method).toBe('POST');
|
||||
expect(endpoint).toBe('/activities');
|
||||
expect(body.due_date).toBe('2026-04-01');
|
||||
});
|
||||
|
||||
it('activity/update strips the time component from due_date', async () => {
|
||||
const ctx = buildContext({
|
||||
rawCustomFieldKeys: true,
|
||||
activityId: 10,
|
||||
updateFields: { due_date: '2026-04-02T00:00:00.000Z' },
|
||||
});
|
||||
|
||||
await activityUpdateExecute.call(ctx);
|
||||
|
||||
const [, method, endpoint, body] = mockApiRequest.call.mock.calls[0] as [
|
||||
unknown,
|
||||
string,
|
||||
string,
|
||||
IDataObject,
|
||||
];
|
||||
expect(method).toBe('PATCH');
|
||||
expect(endpoint).toBe('/activities/10');
|
||||
expect(body.due_date).toBe('2026-04-02');
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ describe('Test PipedriveV2, activity => create', () => {
|
|||
deal_id: 8,
|
||||
person_id: 10,
|
||||
org_id: 7,
|
||||
due_date: '2026-04-01',
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@
|
|||
"additionalFields": {
|
||||
"deal_id": 8,
|
||||
"person_id": 10,
|
||||
"org_id": 7
|
||||
"org_id": 7,
|
||||
"due_date": "2026-04-01T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ describe('Test PipedriveV2, activity => update', () => {
|
|||
.patch('/activities/10', {
|
||||
subject: 'Updated call',
|
||||
done: true,
|
||||
due_date: '2026-04-02',
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
"activityId": 10,
|
||||
"updateFields": {
|
||||
"subject": "Updated call",
|
||||
"done": true
|
||||
"done": true,
|
||||
"due_date": "2026-04-02T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"id": "22222222-3333-4444-5555-666666666666",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import nock from 'nock';
|
||||
|
||||
import { credentials } from '../credentials';
|
||||
import { mockFieldsApi } from '../fieldsApiMock';
|
||||
|
||||
describe('Test PipedriveV2, deal => create (date-only normalisation)', () => {
|
||||
mockFieldsApi('deal');
|
||||
|
||||
nock('https://api.pipedrive.com/api/v2')
|
||||
.post('/deals', {
|
||||
title: 'Test Deal',
|
||||
org_id: 7,
|
||||
expected_close_date: '2026-04-13',
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
data: {
|
||||
id: 9,
|
||||
title: 'Test Deal',
|
||||
creator_user_id: 25455458,
|
||||
value: null,
|
||||
person_id: null,
|
||||
org_id: 7,
|
||||
stage_id: 6,
|
||||
currency: 'USD',
|
||||
add_time: '2026-04-01T22:03:27Z',
|
||||
update_time: null,
|
||||
status: 'open',
|
||||
probability: null,
|
||||
lost_reason: null,
|
||||
visible_to: 3,
|
||||
close_time: null,
|
||||
pipeline_id: 2,
|
||||
won_time: null,
|
||||
lost_time: null,
|
||||
stage_change_time: null,
|
||||
local_won_date: null,
|
||||
local_lost_date: null,
|
||||
local_close_date: null,
|
||||
expected_close_date: '2026-04-13',
|
||||
custom_fields: {
|
||||
test_string: null,
|
||||
test_enum: null,
|
||||
test_multi: null,
|
||||
},
|
||||
owner_id: 25455458,
|
||||
label_ids: [],
|
||||
is_deleted: false,
|
||||
origin: 'API',
|
||||
origin_id: null,
|
||||
channel: null,
|
||||
channel_id: null,
|
||||
acv: null,
|
||||
arr: null,
|
||||
mrr: null,
|
||||
is_archived: false,
|
||||
archive_time: null,
|
||||
},
|
||||
});
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
credentials,
|
||||
workflowFiles: ['createWithDate.workflow.json'],
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
{
|
||||
"name": "Pipedrive v2 deal create with date test",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "aa111111-bb22-cc33-dd44-ee5555555555",
|
||||
"name": "When clicking \"Execute Workflow\"",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [0, 0]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"authentication": "apiToken",
|
||||
"resource": "deal",
|
||||
"operation": "create",
|
||||
"title": "Test Deal",
|
||||
"associateWith": "organization",
|
||||
"org_id": 7,
|
||||
"additionalFields": {
|
||||
"expected_close_date": "2026-04-13T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"id": "bb222222-cc33-dd44-ee55-ff6666666666",
|
||||
"name": "Pipedrive",
|
||||
"type": "n8n-nodes-base.pipedrive",
|
||||
"typeVersion": 2,
|
||||
"position": [200, 0],
|
||||
"credentials": {
|
||||
"pipedriveApi": {
|
||||
"id": "cred-1",
|
||||
"name": "Pipedrive API"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "cc333333-dd44-ee55-ff66-aa7777777777",
|
||||
"name": "No Operation, do nothing",
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [400, 0]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing": [
|
||||
{
|
||||
"json": {
|
||||
"id": 9,
|
||||
"title": "Test Deal",
|
||||
"creator_user_id": 25455458,
|
||||
"value": null,
|
||||
"person_id": null,
|
||||
"org_id": 7,
|
||||
"stage_id": 6,
|
||||
"currency": "USD",
|
||||
"add_time": "2026-04-01T22:03:27Z",
|
||||
"update_time": null,
|
||||
"status": "open",
|
||||
"probability": null,
|
||||
"lost_reason": null,
|
||||
"visible_to": 3,
|
||||
"close_time": null,
|
||||
"pipeline_id": 2,
|
||||
"won_time": null,
|
||||
"lost_time": null,
|
||||
"stage_change_time": null,
|
||||
"local_won_date": null,
|
||||
"local_lost_date": null,
|
||||
"local_close_date": null,
|
||||
"expected_close_date": "2026-04-13",
|
||||
"custom_fields": {
|
||||
"test_string": null,
|
||||
"test_enum": null,
|
||||
"test_multi": null
|
||||
},
|
||||
"owner_id": 25455458,
|
||||
"label_ids": [],
|
||||
"is_deleted": false,
|
||||
"origin": "API",
|
||||
"origin_id": null,
|
||||
"channel": null,
|
||||
"channel_id": null,
|
||||
"acv": null,
|
||||
"arr": null,
|
||||
"mrr": null,
|
||||
"is_archived": false,
|
||||
"archive_time": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking \"Execute Workflow\"": {
|
||||
"main": [[{ "node": "Pipedrive", "type": "main", "index": 0 }]]
|
||||
},
|
||||
"Pipedrive": {
|
||||
"main": [[{ "node": "No Operation, do nothing", "type": "main", "index": 0 }]]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ describe('Test PipedriveV2, deal => update', () => {
|
|||
.patch('/deals/10', {
|
||||
title: 'Updated Deal',
|
||||
value: 7500,
|
||||
expected_close_date: '2026-04-13',
|
||||
})
|
||||
.reply(200, {
|
||||
success: true,
|
||||
|
|
@ -37,7 +38,7 @@ describe('Test PipedriveV2, deal => update', () => {
|
|||
local_won_date: null,
|
||||
local_lost_date: null,
|
||||
local_close_date: null,
|
||||
expected_close_date: null,
|
||||
expected_close_date: '2026-04-13',
|
||||
custom_fields: {
|
||||
f5ed368466cf0477371c6ee076252f49a188848e: null,
|
||||
'48233ee3e9d505bbcca8e7e05d9b8df4231021bc': null,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@
|
|||
"dealId": 10,
|
||||
"updateFields": {
|
||||
"title": "Updated Deal",
|
||||
"value": 7500
|
||||
"value": 7500,
|
||||
"expected_close_date": "2026-04-13T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"id": "b8b8b8b8-1111-2222-3333-444444444444",
|
||||
|
|
@ -67,7 +68,7 @@
|
|||
"local_won_date": null,
|
||||
"local_lost_date": null,
|
||||
"local_close_date": null,
|
||||
"expected_close_date": null,
|
||||
"expected_close_date": "2026-04-13",
|
||||
"custom_fields": {
|
||||
"test_string": null,
|
||||
"test_enum": null,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
encodeCustomFieldsV2,
|
||||
resolveCustomFieldsV2,
|
||||
coerceToBoolean,
|
||||
toRfc3339,
|
||||
toDateOnly,
|
||||
addFieldsToBody,
|
||||
} from '../../helpers';
|
||||
import { customFieldsCollection, rawCustomFieldKeysOption } from '../common.description';
|
||||
|
|
@ -138,7 +138,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
|
|||
addFieldsToBody(body, additionalFields);
|
||||
|
||||
if (body.due_date) {
|
||||
body.due_date = toRfc3339(body.due_date as string);
|
||||
body.due_date = toDateOnly(body.due_date as string);
|
||||
}
|
||||
|
||||
if (customProperties) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
encodeCustomFieldsV2,
|
||||
resolveCustomFieldsV2,
|
||||
coerceToBoolean,
|
||||
toRfc3339,
|
||||
toDateOnly,
|
||||
addFieldsToBody,
|
||||
} from '../../helpers';
|
||||
import { customFieldsCollection, rawCustomFieldKeysOption } from '../common.description';
|
||||
|
|
@ -161,7 +161,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
|
|||
}
|
||||
|
||||
if (body.due_date) {
|
||||
body.due_date = toRfc3339(body.due_date as string);
|
||||
body.due_date = toDateOnly(body.due_date as string);
|
||||
}
|
||||
|
||||
if (customProperties) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
encodeCustomFieldsV2,
|
||||
resolveCustomFieldsV2,
|
||||
coerceToNumber,
|
||||
toRfc3339,
|
||||
toDateOnly,
|
||||
addFieldsToBody,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
|
|
@ -247,7 +247,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
|
|||
addFieldsToBody(body, additionalFields);
|
||||
|
||||
if (body.expected_close_date) {
|
||||
body.expected_close_date = toRfc3339(body.expected_close_date as string);
|
||||
body.expected_close_date = toDateOnly(body.expected_close_date as string);
|
||||
}
|
||||
if (body.value !== undefined) {
|
||||
body.value = coerceToNumber(body.value);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
encodeCustomFieldsV2,
|
||||
resolveCustomFieldsV2,
|
||||
coerceToNumber,
|
||||
toRfc3339,
|
||||
toDateOnly,
|
||||
addFieldsToBody,
|
||||
} from '../../helpers';
|
||||
import {
|
||||
|
|
@ -200,7 +200,7 @@ export async function execute(this: IExecuteFunctions): Promise<INodeExecutionDa
|
|||
}
|
||||
|
||||
if (body.expected_close_date) {
|
||||
body.expected_close_date = toRfc3339(body.expected_close_date as string);
|
||||
body.expected_close_date = toDateOnly(body.expected_close_date as string);
|
||||
}
|
||||
if (body.value !== undefined) {
|
||||
body.value = coerceToNumber(body.value);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export { encodeCustomFieldsV2, resolveCustomFieldsV2 } from './customFields';
|
||||
|
||||
export { coerceToBoolean, coerceToNumber, toRfc3339 } from './typeCoercion';
|
||||
export { coerceToBoolean, coerceToNumber, toDateOnly, toRfc3339 } from './typeCoercion';
|
||||
|
||||
export { addFieldsToBody } from './fields';
|
||||
|
||||
|
|
|
|||
|
|
@ -38,3 +38,24 @@ export function toRfc3339(value: string): string {
|
|||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces any supported date/datetime input to a YYYY-MM-DD string for
|
||||
* Pipedrive fields that only accept a calendar date (e.g. deal
|
||||
* `expected_close_date`, activity `due_date`). The dateTime widget emits
|
||||
* ISO 8601 strings with a time component, which Pipedrive rejects for
|
||||
* these fields.
|
||||
*
|
||||
* Inputs that don't match the expected shapes are returned verbatim so
|
||||
* Pipedrive can surface the real validation error — we deliberately avoid
|
||||
* `new Date(value).toISOString()` here because that converts through UTC
|
||||
* and can shift the calendar day for timezone-less inputs parsed in a
|
||||
* non-UTC server timezone.
|
||||
*/
|
||||
export function toDateOnly(value: string): string {
|
||||
if (!value) return value;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
||||
const datePart = value.split('T')[0].split(' ')[0];
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return datePart;
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user