Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 2 additions & 21 deletions products/workflows/frontend/Broadcasts/broadcastTestSend.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,12 @@
import { HogflowTestResult } from '../Workflows/hogflows/steps/types'
import { findPushTestPartialFailure, findPushTestSkipReason, findTestSendSkipReason } from './broadcastTestSendLogic'
import { findPushTestPartialFailure, findPushTestSkipReason } from './broadcastTestSendLogic'

const result = (partial: Partial<HogflowTestResult>): HogflowTestResult =>
({ status: 'success', nextActionId: null, ...partial }) as HogflowTestResult

const log = (message: string, level = 'info'): any => ({ level, timestamp: '2026-01-01T00:00:00Z', message })

describe('test send skip reasons', () => {
it('finds the reason a declined send reports, even though the run says success', () => {
// The worker declines the send but finishes the step, so status alone would read as sent.
const reason =
'Skipping send: the domain "example.com" has no reachable mail servers, so this message would hard bounce.'
expect(
findTestSendSkipReason(
result({ status: 'success', logs: [log('Executing action [Action:send_test_email]'), log(reason)] })
)
).toBe(reason)
})

it.each([
['a delivered send', result({ status: 'success', logs: [log('Executing action [Action:send_test_email]')] })],
['a run with no logs', result({ status: 'success' })],
['no result at all', null],
])('reports no reason for %s', (_name, input) => {
expect(findTestSendSkipReason(input)).toBeUndefined()
})

describe('push test send outcomes', () => {
it.each([
[
'a person with no registered device',
Expand Down
16 changes: 1 addition & 15 deletions products/workflows/frontend/Broadcasts/broadcastTestSendLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { userLogic } from 'scenes/userLogic'
import { UserType } from '~/types'

import type { HogFlowApi } from '../generated/api.schemas'
import { findTestSendSkipReason } from '../Workflows/hogflows/steps/testSendSkipReason'
import { HogflowTestResult } from '../Workflows/hogflows/steps/types'
import { createExampleEvent } from '../Workflows/hogflows/testEventFactory'
import type { HogFlow, HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types'
Expand All @@ -32,21 +33,6 @@ import {
const TEST_EMAIL_ACTION_ID = 'send_test_email'
const TEST_PUSH_ACTION_ID = 'send_test_push'

// Every reason the email worker declines a send is logged behind this prefix
// (nodejs/src/cdp/services/messaging/email-validation.service.ts).
const SKIP_LOG_PREFIX = 'Skipping send:'

/**
* The reason the worker declined to send, if it did.
*
* A declined send still finishes the step cleanly, so the invocation comes back `success` and the
* reason appears only in the logs. Reading the status alone reports a test as delivered when
* nothing was sent, and the sender waits for mail that never arrives.
*/
export function findTestSendSkipReason(result: HogflowTestResult | null): string | undefined {
return result?.logs?.find((log) => log.message?.includes(SKIP_LOG_PREFIX))?.message
}

// The push worker logs this once per app that took the notification
// (nodejs/src/cdp/services/messaging/push-notification.service.ts).
const PUSH_ACCEPTED_LOG = /accepted by (FCM|APNs)/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function SendTestEmailModal(props: MessageTemplateLogicProps & { isOpen:
sendDisabledReason,
testSendResult,
testSendResultLoading,
testSendSkipReason,
} = useValues(logic)
const { setModalOpen, setRecipientEmail, setSenderIntegrationId, sendTestEmail } = useActions(logic)

Expand Down Expand Up @@ -86,11 +87,8 @@ export function SendTestEmailModal(props: MessageTemplateLogicProps & { isOpen:
</div>
)}
</div>
{testSendResult && testSendResult.status === 'skipped' ? (
<LemonBanner type="warning">
{testSendResult.logs?.map((log) => log.message).join(' ') ||
'The send was skipped for this recipient.'}
</LemonBanner>
{testSendSkipReason ? (
<LemonBanner type="warning">{testSendSkipReason}</LemonBanner>
) : testSendResult && testSendResult.status === 'error' ? (
<LemonBanner type="error">
{testSendResult.errors?.join(', ') || 'Failed to send test email'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,27 +124,42 @@ describe('messageTemplateTestSendLogic', () => {
expect(logic.values.isModalOpen).toBe(false)
})

it('treats a skipped send as a distinct outcome from a failure', async () => {
it.each([
[
'the worker declines the recipient',
{
status: 'success',
emailSendResult: {
status: 'skipped',
reason: 'Skipping send: the domain "example.com" has no reachable mail servers, so this message would hard bounce.',
},
},
'Skipping send: the domain "example.com" has no reachable mail servers, so this message would hard bounce.',
],
[
'the workflow skips the step',
{ status: 'skipped', logs: [{ message: 'Recipient has opted out.' }] },
'Recipient has opted out.',
],
])('reports a skipped send as skipped when %s', async (_name, response, expectedReason) => {
useMocks({
post: {
'/api/environments/:team_id/hog_flows/:id/invocations/': () => [
200,
{ status: 'skipped', logs: [{ message: 'Recipient has opted out.' }] },
],
'/api/environments/:team_id/hog_flows/:id/invocations/': () => [200, response],
},
})

logic.actions.setModalOpen(true)
logic.actions.setRecipientEmail('recipient@example.com')
logic.actions.setRecipientEmail('someone@example.com')

await expectLogic(logic, () => {
logic.actions.sendTestEmail()
}).toDispatchActions(['sendTestEmailSuccess'])

expect(mockToast.warning).toHaveBeenCalledWith('Test email skipped, see details below')
expect(mockToast.success).not.toHaveBeenCalled()
expect(mockToast.error).not.toHaveBeenCalled()
expect(logic.values.isModalOpen).toBe(true)
expect(logic.values.testSendResult?.status).toBe('skipped')
expect(logic.values.testSendSkipReason).toBe(expectedReason)
})

it('keeps the modal open and surfaces the error when the send fails', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
senderFrom,
senderIntegrationFor,
} from '../../../../frontend/src/scenes/hog-functions/email-templater/emailSenderFields'
import { findTestSendSkipReason } from '../Workflows/hogflows/steps/testSendSkipReason'
import { HogflowTestResult } from '../Workflows/hogflows/steps/types'
import { createExampleEvent } from '../Workflows/hogflows/testEventFactory'
import type { HogFlow, HogFlowAction, HogFlowEdge } from '../Workflows/hogflows/types'
Expand All @@ -40,6 +41,7 @@ export interface messageTemplateTestSendLogicValues {
senderIntegrationIdOverride: number | null
testSendResult: HogflowTestResult | null
testSendResultLoading: boolean
testSendSkipReason: string | undefined
}

// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
Expand Down Expand Up @@ -106,6 +108,7 @@ export interface messageTemplateTestSendLogicMeta {
recipientEmail: string,
senderIntegrationId: number | null
) => string | undefined
testSendSkipReason: (testSendResult: HogflowTestResult | null) => string | undefined
}
}

Expand Down Expand Up @@ -226,6 +229,15 @@ export const messageTemplateTestSendLogic = kea<messageTemplateTestSendLogicType
emailIntegrations[0]?.id ??
null,
],
testSendSkipReason: [
(s) => [s.testSendResult],
(testSendResult: HogflowTestResult | null): string | undefined =>
findTestSendSkipReason(testSendResult) ??
(testSendResult?.status === 'skipped'
? testSendResult.logs?.map((log) => log.message).join(' ') ||
'The send was skipped for this recipient.'
: undefined),
],
sendDisabledReason: [
(s) => [s.template, s.recipientEmail, s.senderIntegrationId],
(
Expand Down Expand Up @@ -260,12 +272,12 @@ export const messageTemplateTestSendLogic = kea<messageTemplateTestSendLogicType
}
},
sendTestEmailSuccess: ({ testSendResult }) => {
if (testSendResult.status === 'success') {
if (values.testSendSkipReason) {
lemonToast.warning('Test email skipped, see details below')
} else if (testSendResult.status === 'success') {
lemonToast.success(`Test email sent to ${values.recipientEmail}`)
actions.setModalOpen(false)
actions.clearTestSendResult()
} else if (testSendResult.status === 'skipped') {
lemonToast.warning('Test email skipped, see details below')
} else {
lemonToast.error('Failed to send test email')
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { TRIGGER_NODE_ID, workflowLogic } from '../../../workflowLogic'
import { hogFlowEditorLogic } from '../../hogFlowEditorLogic'
import { hogFlowEditorNotificationTestLogic } from './hogFlowEditorNotificationTestLogic'
import { reorderGlobalsForEmailAction } from './hogFlowEditorNotificationTestLogic'
import { testResultBanner } from './testResultBanner'

export function EmailActionTestContent(): JSX.Element | null {
const { workflow, selectedNode } = useValues(hogFlowEditorLogic)
Expand Down Expand Up @@ -359,21 +360,7 @@ export function EmailActionTestContent(): JSX.Element | null {
<div className="text-muted text-sm">No tests run yet</div>
) : (
<>
<LemonBanner
type={
testResult.status === 'success'
? 'success'
: testResult.status === 'skipped'
? 'warning'
: 'error'
}
>
{testResult.status === 'success'
? 'Success'
: testResult.status === 'skipped'
? 'Workflow was skipped because the event did not match the filter criteria'
: 'Error: ' + testResult.errors?.join(', ')}
</LemonBanner>
<LemonBanner {...testResultBanner(testResult)} />

<div className="flex flex-col gap-2">
<LemonLabel>Logs</LemonLabel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { TRIGGER_NODE_ID, workflowLogic } from '../../../workflowLogic'
import { hogFlowEditorLogic } from '../../hogFlowEditorLogic'
import { HogFlowEditorTestEvent } from './HogFlowEditorTestEvent'
import { hogFlowEditorTestLogic } from './hogFlowEditorTestLogic'
import { testResultBanner } from './testResultBanner'

export function HogFlowTestPanelNonSelected(): JSX.Element {
return (
Expand Down Expand Up @@ -148,21 +149,7 @@ export function HogFlowEditorPanelTest(): JSX.Element | null {
<div className="text-muted text-sm">No tests run yet</div>
) : (
<>
<LemonBanner
type={
testResult.status === 'success'
? 'success'
: testResult.status === 'skipped'
? 'warning'
: 'error'
}
>
{testResult.status === 'success'
? 'Success'
: testResult.status === 'skipped'
? 'Workflow was skipped because the event did not match the filter criteria'
: 'Error: ' + testResult.errors?.join(', ')}
</LemonBanner>
<LemonBanner {...testResultBanner(testResult)} />

<div className="flex flex-col gap-2">
<LemonLabel>Logs</LemonLabel>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { HogflowTestResult } from '../../steps/types'
import { testResultBanner } from './testResultBanner'

const result = (partial: Partial<HogflowTestResult>): HogflowTestResult =>
({ status: 'success', nextActionId: null, ...partial }) as HogflowTestResult

describe('testResultBanner', () => {
it.each([
[
'a delivered email',
result({ emailSendResult: { status: 'accepted' } }),
{ type: 'success', children: 'Success' },
],
[
'a declined email',
result({
emailSendResult: {
status: 'skipped',
reason: 'Skipping send: the domain "example.com" has no reachable mail servers, so this message would hard bounce.',
},
}),
{
type: 'warning',
children:
'Skipping send: the domain "example.com" has no reachable mail servers, so this message would hard bounce.',
},
],
[
'a run the filters skipped',
result({ status: 'skipped' }),
{ type: 'warning', children: 'Workflow was skipped because the event did not match the filter criteria' },
],
[
'a failed run',
result({ status: 'error', errors: ['Template not found', 'Missing input'] }),
{ type: 'error', children: 'Error: Template not found, Missing input' },
],
])('describes %s', (_name, input, expected) => {
expect(testResultBanner(input)).toEqual(expected)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { findTestSendSkipReason } from '../../steps/testSendSkipReason'
import { HogflowTestResult } from '../../steps/types'

export interface TestResultBannerProps {
type: 'success' | 'warning' | 'error'
children: string
}

export function testResultBanner(testResult: HogflowTestResult): TestResultBannerProps {
const skipReason = findTestSendSkipReason(testResult)
if (skipReason) {
return { type: 'warning', children: skipReason }
}
if (testResult.status === 'skipped') {
return { type: 'warning', children: 'Workflow was skipped because the event did not match the filter criteria' }
}
if (testResult.status === 'error') {
return { type: 'error', children: 'Error: ' + testResult.errors?.join(', ') }
}
return { type: 'success', children: 'Success' }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { findTestSendSkipReason } from './testSendSkipReason'
import { HogflowTestResult } from './types'

const result = (partial: Partial<HogflowTestResult>): HogflowTestResult =>
({ status: 'success', nextActionId: null, ...partial }) as HogflowTestResult

describe('findTestSendSkipReason', () => {
it.each([
'Skipping send: the domain "example.com" has no reachable mail servers, so this message would hard bounce.',
'Recipient has opted out, skipping message delivery.',
])('finds the reason a declined send reports, even though the run says success: %s', (reason) => {
expect(
findTestSendSkipReason(result({ status: 'success', emailSendResult: { status: 'skipped', reason } }))
).toBe(reason)
})

it.each([
['a delivered send', result({ status: 'success', emailSendResult: { status: 'accepted' } })],
['a run without an email send', result({ status: 'success' })],
['no result at all', null],
])('reports no reason for %s', (_name, input) => {
expect(findTestSendSkipReason(input)).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { HogflowTestResult } from './types'

// A declined send still finishes the step, so the run status reads `success` when nothing was sent.
export function findTestSendSkipReason(result: HogflowTestResult | null): string | undefined {
const emailSendResult = result?.emailSendResult
if (emailSendResult?.status !== 'skipped') {
return undefined
}
return emailSendResult.reason || 'Nothing was sent to this recipient.'
}
Loading