Skip to content

Commit

Permalink
Merge pull request #10 from StackAdapt/jc/onDelete-handle
Browse files Browse the repository at this point in the history
[ITE-146] Update Segment Engage Destination to handle onDelete events
  • Loading branch information
illumin04 authored Oct 3, 2024
2 parents 6a5b7af + 4a354e7 commit ef7615a
Show file tree
Hide file tree
Showing 6 changed files with 324 additions and 1 deletion.
1 change: 1 addition & 0 deletions .husky/gitleaks-rules.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ paths = [
regexes = ['''219-09-9999''', '''078-05-1120''', '''(9[0-9]{2}|666)-\d{2}-\d{4}''']

[[rules]]
id = "facebook_access_token"
description = "Facebook System User Access Token"
regex = '''EAA[0-9A-Za-z]{100,}'''
tags = ["token", "Facebook Token"]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import nock from 'nock'
import { createTestEvent, createTestIntegration } from '@segment/actions-core'
import Definition from '../../index'
import { SegmentEvent } from '@segment/actions-core/*'

const testDestination = createTestIntegration(Definition)
const mockGqlKey = 'test-graphql-key'

const gqlHostUrl = 'https://api.stackadapt.com'
const gqlPath = '/graphql'
const mockUserId = 'user-id'
const mockAdvertiserId = '23'
const mockMappings = { advertiser_id: mockAdvertiserId }

const deleteEventPayload: Partial<SegmentEvent> = {
userId: mockUserId,
type: 'identify',
context: {
personas: {
computation_class: 'audience',
computation_key: 'first_time_buyer',
computation_id: 'aud_123'
}
}
}

// Helper function to mock the token query response
const mockTokenQueryResponse = (nodes: Array<{ advertiser: { id: string } }>) => {
nock(gqlHostUrl)
.post(gqlPath)
.reply(200, {
data: {
tokenInfo: {
scopesByAdvertiser: {
nodes: nodes
}
}
}
})
}

// Helper function to mock the profile deletion mutation
const mockDeleteProfilesMutation = (
deleteRequestBodyRef: { body?: any },
userErrors: Array<{ message: string }> = []
) => {
nock(gqlHostUrl)
.post(gqlPath, (body) => {
deleteRequestBodyRef.body = body
return body
})
.reply(200, {
data: {
deleteProfilesWithExternalIds: {
userErrors: userErrors
}
}
})
}
// helper for expected delete profiles mutation
const expectDeleteProfilesMutation = (
deleteRequestBody: { body?: any },
expectedExternalIds: string[],
expectedAdvertiserIds: string[]
) => {
expect(deleteRequestBody.body).toMatchInlineSnapshot(`
Object {
"query": "mutation {
deleteProfilesWithExternalIds(
externalIds: [\\"${expectedExternalIds.join('\\", \\"')}\\"],
advertiserIDs: [\\"${expectedAdvertiserIds.join('\\", \\"')}\\"],
externalProvider: \\"segmentio\\"
) {
userErrors {
message
path
}
}
}",
}
`)
}

describe('onDelete action', () => {
afterEach(() => {
nock.cleanAll()
})

it('should delete a profile successfully', async () => {
const deleteRequestBody: { body?: any } = {}

mockTokenQueryResponse([{ advertiser: { id: mockAdvertiserId } }])
mockDeleteProfilesMutation(deleteRequestBody)

const event = createTestEvent({
userId: mockUserId,
type: 'identify',
context: {
personas: {
computation_class: 'audience',
computation_key: 'first_time_buyer',
computation_id: 'aud_123'
}
}
})

const responses = await testDestination.testAction('onDelete', {
event,
useDefaultMappings: true,
mapping: { userId: mockUserId },
settings: { apiKey: mockGqlKey }
})

expect(responses.length).toBe(2)
expectDeleteProfilesMutation(deleteRequestBody, ['user-id'], ['23'])
})

it('should throw error if no advertiser ID is found', async () => {
mockTokenQueryResponse([]) // Pass an empty array to mock no advertiser IDs

const event = createTestEvent(deleteEventPayload)

await expect(
testDestination.testAction('onDelete', {
event,
useDefaultMappings: true,
mapping: mockMappings,
settings: { apiKey: mockGqlKey }
})
).rejects.toThrow('No advertiser ID found.')
})

it('should throw error if profile deletion fails', async () => {
const deleteRequestBody: { body?: any } = {}

mockTokenQueryResponse([{ advertiser: { id: mockAdvertiserId } }])
mockDeleteProfilesMutation(deleteRequestBody, [{ message: 'Deletion failed' }])

const event = createTestEvent(deleteEventPayload)

await expect(
testDestination.testAction('onDelete', {
event,
useDefaultMappings: true,
mapping: mockMappings,
settings: { apiKey: mockGqlKey }
})
).rejects.toThrow('Profile deletion was not successful: Deletion failed')
})

it('should perform onDelete with a userID and two advertiserIDs from a single token request', async () => {
const deleteRequestBody: { body?: any } = {}

const event: Partial<SegmentEvent> = {
userId: 'user-id-1',
type: 'identify',
traits: {
first_time_buyer: true
},
context: {
personas: {
computation_class: 'audience',
computation_key: 'first_time_buyer',
computation_id: 'aud_123'
}
}
}

mockTokenQueryResponse([{ advertiser: { id: 'advertiser-id-1' } }, { advertiser: { id: 'advertiser-id-2' } }])
mockDeleteProfilesMutation(deleteRequestBody)

const responses = await testDestination.testAction('onDelete', {
event,
useDefaultMappings: true,
mapping: { userId: 'user-id-1' },
settings: { apiKey: mockGqlKey }
})

expect(responses[0].status).toBe(200)
expectDeleteProfilesMutation(deleteRequestBody, ['user-id-1'], ['advertiser-id-1', 'advertiser-id-2'])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { GQL_ENDPOINT, EXTERNAL_PROVIDER } from '../functions'

export async function onDelete(request: any, payload: any[]) {
return (async () => {
const userId = payload[0].userId ?? payload[0].anonymousId
const TokenQuery = `query TokenInfo {
tokenInfo {
scopesByAdvertiser {
nodes {
advertiser {
id
}
}
totalCount
}
}
}`

const res_token = await request(GQL_ENDPOINT, {
body: JSON.stringify({ query: TokenQuery }),
throwHttpErrors: false
})

if (res_token.status !== 200) {
throw new Error('Failed to fetch advertiser information: ' + res_token.statusText)
}

const result_token = await res_token.json()
const advertiserNode = result_token.data?.tokenInfo?.scopesByAdvertiser?.nodes

if (!advertiserNode || advertiserNode.length === 0) {
throw new Error('No advertiser ID found.')
}

const advertiserIds = advertiserNode.map((node: { advertiser: { id: string } }) => node.advertiser.id)

const formattedExternalIds = `["${userId}"]`
const formattedAdvertiserIds = `[${advertiserIds.map((id: string) => `"${id}"`).join(', ')}]`

const query = `mutation {
deleteProfilesWithExternalIds(
externalIds: ${formattedExternalIds},
advertiserIDs: ${formattedAdvertiserIds},
externalProvider: "${EXTERNAL_PROVIDER}"
) {
userErrors {
message
path
}
}
}`

const res = await request(GQL_ENDPOINT, {
body: JSON.stringify({ query }),
throwHttpErrors: false
})

if (res.status !== 200) {
throw new Error('Failed to delete profile: ' + res.statusText)
}

const result = await res.json()

if (result.data.deleteProfilesWithExternalIds.userErrors.length > 0) {
throw new Error(
'Profile deletion was not successful: ' +
result.data.deleteProfilesWithExternalIds.userErrors.map((e: any) => e.message).join(', ')
)
}

return result
})()
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ActionDefinition } from '@segment/actions-core'
import { Settings } from '../generated-types'
import { onDelete } from './functions'
import { Payload } from './generated-types'

const action: ActionDefinition<Settings, Payload> = {
title: 'Delete Profiles',
description: 'Deletes a profile by userId or anonymousId and advertiser IDs.',
fields: {
userId: {
label: 'User ID',
description: 'The user ID to delete.',
type: 'string',
required: false
},
anonymousId: {
label: 'Anonymous ID',
description: 'The anonymous ID to delete.',
type: 'string',
required: false
},
advertiserId: {
label: 'Advertiser IDs',
description: 'Comma-separated list of advertiser IDs. If not provided, it will query the token info.',
type: 'string',
required: false
},
externalProvider: {
label: 'External Provider',
description: 'The external provider to delete the profile from.',
type: 'string',
required: false
}
},
perform: async (request, { payload }) => {
// For single profile deletion
return await onDelete(request, [payload])
},
performBatch: async (request, { payload }) => {
// For batch profile deletion
return await onDelete(request, payload)
}
}

export default action
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Settings } from './generated-types'

import forwardProfile from './forwardProfile'
import forwardAudienceEvent from './forwardAudienceEvent'
import onDelete from './deleteProfile'
import { AdvertiserScopesResponse } from './types'
import { GQL_ENDPOINT } from './functions'

Expand Down Expand Up @@ -61,7 +62,8 @@ const destination: DestinationDefinition<Settings> = {
},
actions: {
forwardProfile,
forwardAudienceEvent
forwardAudienceEvent,
onDelete
}
}

Expand Down

0 comments on commit ef7615a

Please sign in to comment.