diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/multi-label-associations.test.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/multi-label-associations.test.ts new file mode 100644 index 0000000000..501f367bce --- /dev/null +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/__tests__/multi-label-associations.test.ts @@ -0,0 +1,473 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' +import Definition from '../../index' +import { Settings } from '../../generated-types' +import { HUBSPOT_BASE_URL } from '../../properties' + +let testDestination = createTestIntegration(Definition) +const settings: Settings = {} + +const payload = { + event: 'Test Custom Object Event', + type: 'track', + userId: 'user_id_1', + properties: { + email: 'test@test.com', + company_id: 'company_id_1' + } +} as Partial + +const mapping = { + __segment_internal_sync_mode: 'upsert', + object_details: { + object_type: 'contact', + id_field_name: 'email', + id_field_value: { '@path': '$.properties.email' }, + property_group: 'contactinformation' + }, + association_sync_mode: 'upsert', + enable_batching: true, + batch_size: 100, + associations: [ + { + object_type: 'company', + association_label: 'HUBSPOT_DEFINED:279', + id_field_name: 'kompany', + id_field_value: { '@path': '$.properties.company_id' } + }, + { + object_type: 'company', + association_label: 'HUBSPOT_DEFINED:1', + id_field_name: 'kompany', + id_field_value: { '@path': '$.properties.company_id' } + } + ] +} + +beforeEach((done) => { + testDestination = createTestIntegration(Definition) + nock.cleanAll() + done() +}) + +describe('Hubspot.upsertObject', () => { + describe('associated records referenced under multiple association labels', () => { + it('should upsert the associated record once, and create one association per label', async () => { + const upsertObjectReq = { + inputs: [ + { + idProperty: 'email', + id: 'test@test.com', + properties: { + email: 'test@test.com' + } + } + ] + } + + const upsertObjectResp = { + results: [ + { + id: '62102303560', + properties: { + email: 'test@test.com' + } + } + ] + } + + // company_id_1 is referenced by both association labels, but must appear only once in this request + const upsertAssocCompanyRecordReq = { + inputs: [ + { + idProperty: 'kompany', + id: 'company_id_1', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + const upsertAssocCompanyRecordResp = { + results: [ + { + id: '798758764867', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + // both labels are still associated to the same company record + const upsertCompanyAssociationReq = { + inputs: [ + { + types: [ + { + associationCategory: 'HUBSPOT_DEFINED', + associationTypeId: '279' + } + ], + from: { + id: '62102303560' + }, + to: { + id: '798758764867' + } + }, + { + types: [ + { + associationCategory: 'HUBSPOT_DEFINED', + associationTypeId: '1' + } + ], + from: { + id: '62102303560' + }, + to: { + id: '798758764867' + } + } + ] + } + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/upsert', upsertObjectReq).reply(200, upsertObjectResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/company/batch/upsert', upsertAssocCompanyRecordReq) + .reply(200, upsertAssocCompanyRecordResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v4/associations/contact/company/batch/create', upsertCompanyAssociationReq) + .reply(200) + + const responses = await testDestination.testAction('upsertObject', { + event: createTestEvent(payload), + settings, + useDefaultMappings: true, + mapping + }) + + expect(responses.length).toBe(3) + expect(nock.isDone()).toBe(true) + }) + + it('should read the associated record once when the association sync mode is read', async () => { + const upsertObjectReq = { + inputs: [ + { + idProperty: 'email', + id: 'test@test.com', + properties: { + email: 'test@test.com' + } + } + ] + } + + const upsertObjectResp = { + results: [ + { + id: '62102303560', + properties: { + email: 'test@test.com' + } + } + ] + } + + const readAssocCompanyRecordReq = { + idProperty: 'kompany', + properties: ['kompany'], + inputs: [{ id: 'company_id_1' }] + } + + const readAssocCompanyRecordResp = { + results: [ + { + id: '798758764867', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + const upsertCompanyAssociationReq = { + inputs: [ + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '279' }], + from: { id: '62102303560' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: '62102303560' }, + to: { id: '798758764867' } + } + ] + } + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/upsert', upsertObjectReq).reply(200, upsertObjectResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/company/batch/read', readAssocCompanyRecordReq) + .reply(200, readAssocCompanyRecordResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v4/associations/contact/company/batch/create', upsertCompanyAssociationReq) + .reply(200) + + const responses = await testDestination.testAction('upsertObject', { + event: createTestEvent(payload), + settings, + useDefaultMappings: true, + mapping: { ...mapping, association_sync_mode: 'read' } + }) + + expect(responses.length).toBe(3) + expect(nock.isDone()).toBe(true) + }) + }) + + describe('multiple records associated to the same record under the same label', () => { + it('should keep one association per record instead of de-duplicating across records', async () => { + const payload2 = { + ...payload, + properties: { + email: 'test2@test.com', + company_id: 'company_id_1' + } + } as Partial + + const singleLabelMapping = { + ...mapping, + associations: [ + { + object_type: 'company', + association_label: 'HUBSPOT_DEFINED:1', + id_field_name: 'kompany', + id_field_value: { '@path': '$.properties.company_id' } + } + ] + } + + const upsertObjectReq = { + inputs: [ + { + idProperty: 'email', + id: 'test@test.com', + properties: { + email: 'test@test.com' + } + }, + { + idProperty: 'email', + id: 'test2@test.com', + properties: { + email: 'test2@test.com' + } + } + ] + } + + const upsertObjectResp = { + results: [ + { + id: 'hubspot_contact_id_value_1', + properties: { + email: 'test@test.com' + } + }, + { + id: 'hubspot_contact_id_value_2', + properties: { + email: 'test2@test.com' + } + } + ] + } + + // both contacts reference the same company, which must be upserted only once + const upsertAssocCompanyRecordReq = { + inputs: [ + { + idProperty: 'kompany', + id: 'company_id_1', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + const upsertAssocCompanyRecordResp = { + results: [ + { + id: '798758764867', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + // neither contact may lose its association to the shared company + const upsertCompanyAssociationReq = { + inputs: [ + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: 'hubspot_contact_id_value_1' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: 'hubspot_contact_id_value_2' }, + to: { id: '798758764867' } + } + ] + } + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/upsert', upsertObjectReq).reply(200, upsertObjectResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/company/batch/upsert', upsertAssocCompanyRecordReq) + .reply(200, upsertAssocCompanyRecordResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v4/associations/contact/company/batch/create', upsertCompanyAssociationReq) + .reply(200) + + const responses = await testDestination.testBatchAction('upsertObject', { + events: [createTestEvent(payload), createTestEvent(payload2)], + settings, + useDefaultMappings: true, + mapping: singleLabelMapping + }) + + expect(responses.length).toBe(3) + expect(nock.isDone()).toBe(true) + }) + }) + + describe('multiple records, each associated to the same record under multiple labels', () => { + it('should upsert the associated record once and create every record and label combination', async () => { + const payload2 = { + ...payload, + properties: { + email: 'test2@test.com', + company_id: 'company_id_1' + } + } as Partial + + const upsertObjectReq = { + inputs: [ + { + idProperty: 'email', + id: 'test@test.com', + properties: { + email: 'test@test.com' + } + }, + { + idProperty: 'email', + id: 'test2@test.com', + properties: { + email: 'test2@test.com' + } + } + ] + } + + const upsertObjectResp = { + results: [ + { + id: 'hubspot_contact_id_value_1', + properties: { + email: 'test@test.com' + } + }, + { + id: 'hubspot_contact_id_value_2', + properties: { + email: 'test2@test.com' + } + } + ] + } + + // four association payloads (2 contacts x 2 labels) collapse to a single record input + const upsertAssocCompanyRecordReq = { + inputs: [ + { + idProperty: 'kompany', + id: 'company_id_1', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + const upsertAssocCompanyRecordResp = { + results: [ + { + id: '798758764867', + properties: { + kompany: 'company_id_1' + } + } + ] + } + + // ... and fan back out to every contact and label combination + const upsertCompanyAssociationReq = { + inputs: [ + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '279' }], + from: { id: 'hubspot_contact_id_value_1' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: 'hubspot_contact_id_value_1' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '279' }], + from: { id: 'hubspot_contact_id_value_2' }, + to: { id: '798758764867' } + }, + { + types: [{ associationCategory: 'HUBSPOT_DEFINED', associationTypeId: '1' }], + from: { id: 'hubspot_contact_id_value_2' }, + to: { id: '798758764867' } + } + ] + } + + nock(HUBSPOT_BASE_URL).post('/crm/v3/objects/contact/batch/upsert', upsertObjectReq).reply(200, upsertObjectResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v3/objects/company/batch/upsert', upsertAssocCompanyRecordReq) + .reply(200, upsertAssocCompanyRecordResp) + + nock(HUBSPOT_BASE_URL) + .post('/crm/v4/associations/contact/company/batch/create', upsertCompanyAssociationReq) + .reply(200) + + const responses = await testDestination.testBatchAction('upsertObject', { + events: [createTestEvent(payload), createTestEvent(payload2)], + settings, + useDefaultMappings: true, + mapping + }) + + expect(responses.length).toBe(3) + expect(nock.isDone()).toBe(true) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts index f4ac1e80b5..1cca64ceb8 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/hubspot-association-functions.ts @@ -77,6 +77,27 @@ export async function sendAssociatedRecords( } } +/** + * Returns one payload per unique `id_field_value` within a group. + * + * A group can legitimately hold several payloads for the same record - for example when the same record is + * associated under more than one association label. Those payloads must all be kept for the association + * requests, but the record requests only identify records by `id_field_value`, so sending one input per + * payload would repeat the same record within a single batch. HubSpot rejects such a batch. + * + * Grouping guarantees `object_type` and `id_field_name` are identical across the group, so `id_field_value` + * alone is a sufficient key. + */ +function uniqueRecordsById(payloads: AssociationPayload[]): AssociationPayload[] { + const uniquePayloads = new Map() + + for (const payload of payloads) { + uniquePayloads.set(payload.object_details.id_field_value, payload) + } + + return Array.from(uniquePayloads.values()) +} + export async function readAssociatedRecords( client: Client, groupedPayloads: AssociationPayload[][] @@ -87,7 +108,7 @@ export async function readAssociatedRecords( return await client.batchObjectRequest(AssociationSyncMode.Read, objectType, { idProperty: payloads[0].object_details.id_field_name, properties: [payloads[0].object_details.id_field_name], - inputs: payloads.map((payload) => { + inputs: uniqueRecordsById(payloads).map((payload) => { return { id: payload.object_details.id_field_value } @@ -106,7 +127,7 @@ async function upsertAssociatedRecords( const { object_type: objectType } = payloads[0].object_details return await client.batchObjectRequest(AssociationSyncMode.Upsert, objectType, { - inputs: payloads.map((payload) => { + inputs: uniqueRecordsById(payloads).map((payload) => { return { idProperty: payload.object_details.id_field_name, id: payload.object_details.id_field_value, diff --git a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts index 9e07a0e14f..9ae1d9a911 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertObject/functions/validation-functions.ts @@ -250,6 +250,7 @@ export function deDuplicateAssociations(associationGroups: AssociationPayload[][ const associationKey = (assoc: AssociationPayload) => JSON.stringify({ + from_record_id: assoc.object_details.from_record_id, object_type: assoc.object_details.object_type, association_label: assoc.association_details.association_label, id_field_name: assoc.object_details.id_field_name,