From 9dbddda76833da22b74a135547df04594857322d Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Tue, 9 Jun 2026 11:14:10 +0530 Subject: [PATCH 1/2] fix(hubspot): allow string values to match datetime fields in schema comparison HubSpot datetime fields accept plain string values such as Unix epoch timestamps. The schema comparison was incorrectly blocking these by classifying them as string:text and rejecting them against datetime:date fields. Added a coercion rule in compareProps() consistent with existing string:text -> enumeration:select and string:text -> string:textarea rules. Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/schema-functions.test.ts | 77 +++++++++++++++++++ .../upsert-property-mismatch.test.ts | 68 ++++++++++++++++ .../functions/schema-functions.ts | 9 +++ 3 files changed, 154 insertions(+) create mode 100644 packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/schema-functions.test.ts diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/schema-functions.test.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/schema-functions.test.ts new file mode 100644 index 00000000000..91d54cf1196 --- /dev/null +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/schema-functions.test.ts @@ -0,0 +1,77 @@ +import { objectSchema } from '../functions/schema-functions' +import { HSPropType, HSPropFieldType, HSPropTypeFieldType } from '../types' +import { Payload } from '../generated-types' + +function makePayload(properties: Record): Payload { + return { + object_details: { + object_type: 'contact', + id_field_name: 'email', + id_field_value: 'test@test.com' + }, + properties, + association_sync_mode: 'upsert', + enable_batching: true, + batch_size: 100 + } as unknown as Payload +} + +describe('objectSchema / format()', () => { + describe('type detection', () => { + it('classifies a date-only string as date:date', () => { + const schema = objectSchema([makePayload({ churn_date: '2024-01-08' })], 'contact') + const prop = schema.properties.find((p) => p.name === 'churn_date')! + expect(prop.type).toBe(HSPropType.Date) + expect(prop.fieldType).toBe(HSPropFieldType.Date) + expect(prop.typeFieldType).toBe(HSPropTypeFieldType.DateDate) + }) + + it('classifies a full ISO datetime string as datetime:date', () => { + const schema = objectSchema([makePayload({ appt_start: '2024-01-08T13:52:50.212Z' })], 'contact') + const prop = schema.properties.find((p) => p.name === 'appt_start')! + expect(prop.type).toBe(HSPropType.DateTime) + expect(prop.fieldType).toBe(HSPropFieldType.Date) + expect(prop.typeFieldType).toBe(HSPropTypeFieldType.DateTimeDate) + }) + + it('classifies a datetime string with no timezone as datetime:date', () => { + const schema = objectSchema([makePayload({ appt_start: '2024-01-08T00:00:00' })], 'contact') + const prop = schema.properties.find((p) => p.name === 'appt_start')! + expect(prop.type).toBe(HSPropType.DateTime) + expect(prop.fieldType).toBe(HSPropFieldType.Date) + expect(prop.typeFieldType).toBe(HSPropTypeFieldType.DateTimeDate) + }) + + it('classifies a numeric epoch string as string:text', () => { + const schema = objectSchema([makePayload({ appt_start: '1780382814' })], 'contact') + const prop = schema.properties.find((p) => p.name === 'appt_start')! + expect(prop.type).toBe(HSPropType.String) + expect(prop.fieldType).toBe(HSPropFieldType.Text) + expect(prop.typeFieldType).toBe(HSPropTypeFieldType.StringText) + }) + + it('classifies a number as number:number', () => { + const schema = objectSchema([makePayload({ count: 42 })], 'contact') + const prop = schema.properties.find((p) => p.name === 'count')! + expect(prop.type).toBe(HSPropType.Number) + expect(prop.fieldType).toBe(HSPropFieldType.Number) + expect(prop.typeFieldType).toBe(HSPropTypeFieldType.NumberNumber) + }) + + it('classifies a boolean as enumeration:booleancheckbox', () => { + const schema = objectSchema([makePayload({ active: true })], 'contact') + const prop = schema.properties.find((p) => p.name === 'active')! + expect(prop.type).toBe(HSPropType.Enumeration) + expect(prop.fieldType).toBe(HSPropFieldType.BooleanCheckbox) + expect(prop.typeFieldType).toBe(HSPropTypeFieldType.EnumerationBooleanCheckbox) + }) + + it('classifies a plain string as string:text', () => { + const schema = objectSchema([makePayload({ name: 'hello' })], 'contact') + const prop = schema.properties.find((p) => p.name === 'name')! + expect(prop.type).toBe(HSPropType.String) + expect(prop.fieldType).toBe(HSPropFieldType.Text) + expect(prop.typeFieldType).toBe(HSPropTypeFieldType.StringText) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/upsert-property-mismatch.test.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/upsert-property-mismatch.test.ts index 648c5bdbde8..6979c7462e3 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/upsert-property-mismatch.test.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/upsert-property-mismatch.test.ts @@ -183,6 +183,74 @@ beforeEach((done) => { describe('Hubspot.upsertObject', () => { describe('where syncMode = upsert', () => { + describe('Hubspot datetime field receives an epoch string value', () => { + it('should allow a plain string value (e.g. Unix epoch) to match a HubSpot datetime field', async () => { + const epochPayload = { + event: 'Test Custom Object Event', + type: 'track', + userId: 'user_id_1', + properties: { + email: 'test@test.com', + regular: { + datetime_prop: '1780382814' + }, + sensitive: {} + } + } as Partial + + const epochMapping = { + __segment_internal_sync_mode: 'upsert', + object_details: { + object_type: 'contact', + id_field_name: 'email', + id_field_value: { '@path': '$.properties.email' }, + property_group: 'contactinformation' + }, + properties: { '@path': '$.properties.regular' }, + sensitive_properties: { '@path': '$.properties.sensitive' }, + association_sync_mode: 'upsert', + associations: [], + enable_batching: true, + batch_size: 100 + } + + nock(HUBSPOT_BASE_URL) + .get('/crm/v3/properties/contact') + .reply(200, { + results: [ + { + name: 'datetime_prop', + type: 'datetime', + fieldType: 'date', + hasUniqueValue: false + } + ] + }) + + nock(HUBSPOT_BASE_URL).get('/crm/v3/properties/contact?dataSensitivity=sensitive').reply(200, { results: [] }) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/contact/batch/upsert') + .reply(200, { + status: 'COMPLETE', + results: [{ id: '1', properties: { email: 'test@test.com', datetime_prop: '1780382814' } }] + }) + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/read').reply(200, { results: [] }) + + const event = createTestEvent(epochPayload) + + await expect( + testDestination.testAction('upsertObject', { + event, + settings, + useDefaultMappings: true, + mapping: epochMapping + }) + ).resolves.not.toThrow() + }) + }) + describe('Hubspot object schema has a mis-matched property', () => { it('should throw an error explaining the property type mismatch.', async () => { const event = createTestEvent(payload) diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/schema-functions.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/schema-functions.ts index 46d77d865c8..6a7e608b0be 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/schema-functions.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/schema-functions.ts @@ -166,6 +166,15 @@ function compareProps(prop1: Prop, prop2: Prop): boolean { // string:text is OK to match to textarea:string return true } + if ( + prop1.type === HSPropType.String && + prop1.fieldType === HSPropFieldType.Text && + prop2.type === HSPropType.DateTime && + prop2.fieldType === HSPropFieldType.Date + ) { + // string values (e.g. Unix epoch strings) are valid for HubSpot datetime fields + return true + } throw new IntegrationError( `Payload property with name ${prop1.name} has a different type to the property in HubSpot. Expected: type = ${prop1.type} fieldType = ${prop1.fieldType}. Received: type = ${prop2.type} fieldType = ${prop2.fieldType}`, 'HUBSPOT_PROPERTY_TYPE_MISMATCH', From c0a302e220e02619ff6a0e8f1cf1255edb95d0e3 Mon Sep 17 00:00:00 2001 From: Harsh Joshi Date: Tue, 9 Jun 2026 11:29:45 +0530 Subject: [PATCH 2/2] fix(hubspot): make string/datetime coercion symmetric for cache compatibility When the cache stores a payload-inferred string:text schema (from an epoch string event) and a subsequent event sends an ISO datetime string (inferred as datetime:date), the cache comparison would throw a mismatch. Making the coercion bidirectional prevents time-dependent failures when the same HubSpot datetime property arrives in different string encodings across events. Co-Authored-By: Claude Sonnet 4.6 --- .../upsert-property-mismatch.test.ts | 67 +++++++++++++++++++ .../functions/schema-functions.ts | 16 +++-- 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/upsert-property-mismatch.test.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/upsert-property-mismatch.test.ts index 6979c7462e3..1ee97bf02e4 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/upsert-property-mismatch.test.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/upsert-property-mismatch.test.ts @@ -249,6 +249,73 @@ describe('Hubspot.upsertObject', () => { }) ).resolves.not.toThrow() }) + + it('should allow an ISO datetime string to match a cached string:text schema (reverse coercion)', async () => { + const isoPayload = { + event: 'Test Custom Object Event', + type: 'track', + userId: 'user_id_1', + properties: { + email: 'test@test.com', + regular: { + datetime_prop: '2024-01-08T13:52:50.212Z' + }, + sensitive: {} + } + } as Partial + + const isoMapping = { + __segment_internal_sync_mode: 'upsert', + object_details: { + object_type: 'contact', + id_field_name: 'email', + id_field_value: { '@path': '$.properties.email' }, + property_group: 'contactinformation' + }, + properties: { '@path': '$.properties.regular' }, + sensitive_properties: { '@path': '$.properties.sensitive' }, + association_sync_mode: 'upsert', + associations: [], + enable_batching: true, + batch_size: 100 + } + + // HubSpot schema returns string:text (simulating a cache populated by a prior epoch string event) + nock(HUBSPOT_BASE_URL) + .get('/crm/v3/properties/contact') + .reply(200, { + results: [ + { + name: 'datetime_prop', + type: 'string', + fieldType: 'text', + hasUniqueValue: false + } + ] + }) + + nock(HUBSPOT_BASE_URL).get('/crm/v3/properties/contact?dataSensitivity=sensitive').reply(200, { results: [] }) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/contact/batch/upsert') + .reply(200, { + status: 'COMPLETE', + results: [{ id: '1', properties: { email: 'test@test.com', datetime_prop: '2024-01-08T13:52:50.212Z' } }] + }) + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/read').reply(200, { results: [] }) + + const event = createTestEvent(isoPayload) + + await expect( + testDestination.testAction('upsertObject', { + event, + settings, + useDefaultMappings: true, + mapping: isoMapping + }) + ).resolves.not.toThrow() + }) }) describe('Hubspot object schema has a mis-matched property', () => { diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/schema-functions.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/schema-functions.ts index 6a7e608b0be..884e571de95 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/schema-functions.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/schema-functions.ts @@ -167,12 +167,18 @@ function compareProps(prop1: Prop, prop2: Prop): boolean { return true } if ( - prop1.type === HSPropType.String && - prop1.fieldType === HSPropFieldType.Text && - prop2.type === HSPropType.DateTime && - prop2.fieldType === HSPropFieldType.Date + (prop1.type === HSPropType.String && + prop1.fieldType === HSPropFieldType.Text && + prop2.type === HSPropType.DateTime && + prop2.fieldType === HSPropFieldType.Date) || + (prop1.type === HSPropType.DateTime && + prop1.fieldType === HSPropFieldType.Date && + prop2.type === HSPropType.String && + prop2.fieldType === HSPropFieldType.Text) ) { - // string values (e.g. Unix epoch strings) are valid for HubSpot datetime fields + // string values (e.g. Unix epoch strings) are valid for HubSpot datetime fields. + // coercion is symmetric to handle cache hits where the cached schema was inferred + // from a different string encoding (e.g. epoch string cached, ISO string arrives later) return true } throw new IntegrationError(