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..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 @@ -183,6 +183,141 @@ 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() + }) + + 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', () => { 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..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 @@ -166,6 +166,21 @@ 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) || + (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. + // 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( `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',