feat(cdp): add Userlist integration template and tests (#39407)

Forcing in as the failing tests are unrelated
This commit is contained in:
Benedikt Deicke
2025-10-17 16:32:38 +02:00
committed by GitHub
parent 9a28655255
commit 0197cbaac0
6 changed files with 471 additions and 6 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -952,12 +952,6 @@ const destinationDefinitions: DestinationConfig[] = [
icon_url: '/static/coming-soon-destinations/Tune.svg',
category: ['Marketing'],
},
{
name: 'Userlist',
id: 'coming-soon-userlist',
icon_url: '/static/coming-soon-destinations/Userlist.png',
category: ['Marketing'],
},
{
name: 'Vero',
id: 'coming-soon-vero',

View File

@@ -82,6 +82,7 @@ from .sendgrid.template_sendgrid import (
from .slack.template_slack import template as slack
from .snapchat_ads.template_pixel import template_snapchat_pixel as snapchat_pixel
from .tiktok_ads.template_tiktok_pixel import template_tiktok_pixel as tiktok_pixel
from .userlist.template_userlist import template as userlist
from .zapier.template_zapier import template as zapier
from .zendesk.template_zendesk import template as zendesk
@@ -128,6 +129,7 @@ HOG_FUNCTION_TEMPLATES = [
sendgrid,
snapchat_pixel,
tiktok_pixel,
userlist,
zapier,
zendesk,
early_access_features,

View File

@@ -0,0 +1,165 @@
from posthog.cdp.templates.hog_function_template import HogFunctionTemplateDC
template: HogFunctionTemplateDC = HogFunctionTemplateDC(
status="beta",
free=False,
type="destination",
id="template-userlist",
name="Userlist",
description="Send user, company, and event data to Userlist",
icon_url="/static/services/userlist.png",
category=["Email Marketing"],
code_language="hog",
code="""
let base_uri := 'https://incoming.userlist.com/posthog'
fun compact(obj) {
let result := {}
for (let key, value in obj) {
if (value != null) {
result[key] := value
}
}
return result
}
fun push(endpoint, body) {
if (empty(body)) {
print('Error sending data to Userlist: Invalid payload')
return
}
let res := fetch(f'{base_uri}{endpoint}', {
'method': 'POST',
'headers': {
'Content-Type': 'application/json; charset=utf-8',
'Accept': 'application/json',
'Authorization': f'Push {inputs.push_key}',
},
'body': body
})
if (res.status >= 400) {
print(f'Error sending data to Userlist: {res.status} - {res.body}')
}
return res
}
let user_payload := compact({
'identifier': inputs.user_identifier,
'email': inputs.user_email,
'properties': compact(inputs.user_properties)
})
if (empty(user_payload.identifier) and empty(user_payload.email)) {
user_payload := null
}
let company_payload := compact({
'identifier': inputs.company_identifier,
'name': inputs.company_name,
'properties': compact(inputs.company_properties),
})
if (empty(company_payload.identifier)) {
company_payload := null
}
let event_payload := compact({
'name': event.event,
'user': user_payload,
'company': company_payload,
'occurred_at': event.timestamp,
'properties': compact(event.properties)
})
if (empty(event_payload.name) or (empty(event_payload.user) and empty(event_payload.company))) {
event_payload := null
}
if (event.event in ['$identify', '$set']) {
push('/users', user_payload)
} else if (event.event == '$groupidentify') {
if (not empty(company_payload) and not empty(user_payload)) {
company_payload.user := user_payload
}
push('/companies', company_payload)
} else if (match(event.event, '^[a-z][a-z0-9_-]*$')) {
push('/events', event_payload)
} else {
print(f'Skipping event {event.event} as it is not supported.')
return
}
""".strip(),
inputs_schema=[
{
"key": "push_key",
"type": "string",
"label": "Push Key",
"description": "You can find your Push Key in your [Userlist Push settings](https://app.userlist.com/settings/push)",
"secret": True,
"required": True,
},
{
"key": "user_identifier",
"type": "string",
"label": "User Identifier",
"description": "The unique identifier for the user in Userlist.",
"default": "{person.id}",
"required": True,
},
{
"key": "user_email",
"type": "string",
"label": "User Email",
"description": "The email address of the user.",
"default": "{person.properties.email}",
},
{
"key": "user_properties",
"type": "dictionary",
"label": "Custom User Properties",
"description": "Map of custom user properties and their values.",
"default": {
"lastname": "{person.properties.lastname ?? person.properties.lastName ?? person.properties.last_name}",
"firstname": "{person.properties.firstname ?? person.properties.firstName ?? person.properties.first_name}",
},
},
{
"key": "company_identifier",
"type": "string",
"label": "Company Identifier",
"description": "The unique identifier for the company in Userlist.",
"default": "{groups.account.id}",
},
{
"key": "company_name",
"type": "string",
"label": "Company Name",
"description": "The name of the company.",
"default": "{groups.account.properties.name}",
},
{
"key": "company_properties",
"type": "dictionary",
"label": "Custom Company Properties",
"description": "Map of custom company properties and their values.",
"default": {
"industry": "{groups.account.properties.industry}",
},
},
],
filters={
"events": [
{"id": "$identify", "name": "$identify", "type": "events", "order": 0},
{"id": "$set", "name": "$set", "type": "events", "order": 1},
{"id": "$groupidentify", "name": "$groupidentify", "type": "events", "order": 2},
],
"actions": [],
"filter_test_accounts": True,
},
)

View File

@@ -0,0 +1,304 @@
from inline_snapshot import snapshot
from posthog.cdp.templates.helpers import BaseHogFunctionTemplateTest
from posthog.cdp.templates.userlist.template_userlist import template as template_userlist
def create_inputs(**kwargs):
inputs = {
"push_key": "test_push_key",
"user_identifier": "user_123",
"user_email": "user@example.com",
"user_properties": {"first_name": "John", "last_name": "Doe"},
"company_identifier": None,
"company_name": None,
"company_properties": {"industry": None},
}
inputs.update(kwargs)
return inputs
class TestTemplateUserlist(BaseHogFunctionTemplateTest):
template = template_userlist
def test_identify_event(self):
self.run_function(
inputs=create_inputs(),
globals={
"event": {"event": "$identify"},
"person": {
"id": "user_123",
"properties": {"email": "user@example.com"},
},
},
)
assert self.get_mock_fetch_calls()[0] == snapshot(
(
"https://incoming.userlist.com/posthog/users",
{
"method": "POST",
"headers": {
"Authorization": "Push test_push_key",
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
},
"body": {
"email": "user@example.com",
"identifier": "user_123",
"properties": {"first_name": "John", "last_name": "Doe"},
},
},
)
)
def test_set_event(self):
self.run_function(
inputs=create_inputs(
user_identifier="user_456",
user_email="updated@example.com",
user_properties={"first_name": "Jane", "last_name": "Smith"},
),
globals={
"event": {"event": "$set"},
"person": {
"id": "user_456",
"properties": {"email": "updated@example.com"},
},
},
)
assert self.get_mock_fetch_calls()[0] == snapshot(
(
"https://incoming.userlist.com/posthog/users",
{
"method": "POST",
"headers": {
"Authorization": "Push test_push_key",
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
},
"body": {
"email": "updated@example.com",
"identifier": "user_456",
"properties": {"first_name": "Jane", "last_name": "Smith"},
},
},
)
)
def test_groupidentify_event(self):
self.run_function(
inputs=create_inputs(
company_identifier="company_123",
company_name="Acme Corp",
company_properties={"industry": "Technology", "employee_count": "50"},
),
globals={
"event": {"event": "$groupidentify"},
"person": {
"id": "user_123",
"properties": {"email": "user@example.com"},
},
},
)
assert self.get_mock_fetch_calls()[0] == snapshot(
(
"https://incoming.userlist.com/posthog/companies",
{
"method": "POST",
"headers": {
"Authorization": "Push test_push_key",
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
},
"body": {
"identifier": "company_123",
"name": "Acme Corp",
"properties": {"industry": "Technology", "employee_count": "50"},
"user": {
"identifier": "user_123",
"email": "user@example.com",
"properties": {"first_name": "John", "last_name": "Doe"},
},
},
},
)
)
def test_custom_event(self):
self.run_function(
inputs=create_inputs(
company_identifier="company_123",
company_name="Acme Corp",
company_properties={"industry": "Technology"},
),
globals={
"event": {
"event": "button_clicked",
"timestamp": "2024-01-01T00:00:00Z",
"properties": {"button_name": "signup", "page": "homepage"},
},
"person": {
"id": "user_123",
"properties": {"email": "user@example.com"},
},
},
)
assert self.get_mock_fetch_calls()[0] == snapshot(
(
"https://incoming.userlist.com/posthog/events",
{
"method": "POST",
"headers": {
"Authorization": "Push test_push_key",
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
},
"body": {
"name": "button_clicked",
"user": {
"identifier": "user_123",
"email": "user@example.com",
"properties": {"first_name": "John", "last_name": "Doe"},
},
"company": {
"identifier": "company_123",
"name": "Acme Corp",
"properties": {"industry": "Technology"},
},
"occurred_at": "2024-01-01T00:00:00Z",
"properties": {"button_name": "signup", "page": "homepage"},
},
},
)
)
def test_compact_removes_null_values(self):
self.run_function(
inputs=create_inputs(
user_email=None,
user_properties={"first_name": "John", "last_name": None, "middle_name": None},
),
globals={
"event": {"event": "$identify"},
"person": {
"id": "user_123",
"properties": {},
},
},
)
assert self.get_mock_fetch_calls()[0] == snapshot(
(
"https://incoming.userlist.com/posthog/users",
{
"method": "POST",
"headers": {
"Authorization": "Push test_push_key",
"Accept": "application/json",
"Content-Type": "application/json; charset=utf-8",
},
"body": {
"identifier": "user_123",
"properties": {"first_name": "John"},
},
},
)
)
def test_user_payload_without_email_and_identifier_is_not_sent(self):
self.run_function(
inputs=create_inputs(
user_identifier=None,
user_email=None,
user_properties={"first_name": "John"},
),
globals={
"event": {"event": "$identify"},
"person": {
"id": None,
"properties": {},
},
},
)
assert self.get_mock_fetch_calls() == []
assert self.get_mock_print_calls() == snapshot([("Error sending data to Userlist: Invalid payload",)])
def test_company_payload_without_identifier_is_not_sent(self):
self.run_function(
inputs=create_inputs(
user_properties={"first_name": "John"},
company_name="Acme Corp",
company_properties={"industry": "Technology"},
),
globals={
"event": {"event": "$groupidentify"},
"person": {
"id": "user_123",
"properties": {"email": "user@example.com"},
},
},
)
assert self.get_mock_fetch_calls() == []
assert self.get_mock_print_calls() == snapshot([("Error sending data to Userlist: Invalid payload",)])
def test_event_payload_without_user_and_company_is_not_sent(self):
self.run_function(
inputs=create_inputs(
user_identifier=None,
user_email=None,
user_properties={"first_name": "John"},
company_properties={"industry": "Technology"},
),
globals={
"event": {"event": "custom_event", "timestamp": "2024-01-01T00:00:00Z", "properties": {}},
"person": {
"id": None,
"properties": {},
},
},
)
assert self.get_mock_fetch_calls() == []
assert self.get_mock_print_calls() == snapshot([("Error sending data to Userlist: Invalid payload",)])
def test_function_prints_error_on_bad_status(self):
self.mock_fetch_response = lambda *args: {"status": 400, "body": {"error": "Invalid request"}} # type: ignore
self.run_function(
inputs=create_inputs(),
globals={
"event": {"event": "$identify"},
"person": {
"id": "user_123",
"properties": {"email": "user@example.com"},
},
},
)
assert self.get_mock_print_calls() == snapshot(
[("Error sending data to Userlist: 400 - {'error': 'Invalid request'}",)]
)
def test_system_events_are_skipped(self):
self.run_function(
inputs=create_inputs(),
globals={
"event": {
"event": "$pageview",
"timestamp": "2024-01-01T00:00:00Z",
"properties": {"url": "https://example.com"},
},
"person": {
"id": "user_123",
"properties": {"email": "user@example.com"},
},
},
)
assert self.get_mock_fetch_calls() == []
assert self.get_mock_print_calls() == snapshot([("Skipping event $pageview as it is not supported.",)])