Skip to content

test: add integration tests for webhook event processing#305

Open
bh462007 wants to merge 3 commits into
Kuldeeep18:mainfrom
bh462007:test/webhook-event-processing-tests
Open

test: add integration tests for webhook event processing#305
bh462007 wants to merge 3 commits into
Kuldeeep18:mainfrom
bh462007:test/webhook-event-processing-tests

Conversation

@bh462007

@bh462007 bh462007 commented Jun 17, 2026

Copy link
Copy Markdown

Summary

This PR adds a dedicated WebhookEventTests integration test suite for the email webhook endpoint (POST /api/v1/webhooks/email/).

The new tests validate webhook processing across all supported event types, condition-based workflow routing, and various edge cases to ensure webhook events are handled reliably without breaking existing campaign flows.

No production code was modified as part of this change.

Closes #292

What Was Added

A new WebhookEventTests class was added to backend/campaigns/tests.py with coverage for the following scenarios:

Event Processing

  • Bounce events correctly mark leads as BOUNCED
  • Reply events correctly mark leads as REPLIED and stop the active sequence when no condition branch is involved
  • Open events update last_opened_at
  • Click events update last_clicked_at

Conditional Workflow Routing

  • Reply events trigger the appropriate CONDITION_REPLY yes-branch when a matching condition step exists
  • Reply events correctly avoid reply-based routing when the condition is not satisfied
  • Open events trigger CONDITION_OPEN routing
  • Click events trigger CONDITION_CLICK routing

Edge Cases

  • Requests missing an email field are handled gracefully
  • Requests missing an event type are handled gracefully
  • Invalid or unknown message_id values do not cause errors
  • Multiple campaign leads associated with the same message are updated correctly

Files Modified

  • backend/campaigns/tests.py

    • Added the WebhookEventTests integration test suite

Testing

All campaign tests pass successfully with the new coverage:

Found 58 test(s).
----------------------------------------------------------------------
Ran 58 tests in 18.210s

OK

Run locally with:

cd backend
python3 manage.py test campaigns

Type of Change

  • Testing (new integration tests, no production code changes)

Checklist

  • Added coverage for all scenarios described in the issue
  • Verified status transitions for supported webhook event types
  • Verified conditional branch routing behavior
  • Covered edge cases including missing fields and invalid identifiers
  • Confirmed existing tests continue to pass
  • Full campaigns test suite passes successfully

Summary by CodeRabbit

  • New Features

    • Enhanced Leads page filter panel with active filters display
    • Reorganized table layouts for improved clarity
    • Updated CSV upload workflow with processing status feedback
  • Improvements

    • Refined empty-state and error messaging on Leads page
  • Tests

    • Added comprehensive test coverage for campaign launch endpoint
    • Added webhook event handling test suites

Copilot AI review requested due to automatic review settings June 17, 2026 18:50
@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Two test suites are added to backend/campaigns/tests.py: CampaignLaunchTests covers the campaign launch endpoint's validation and Celery dispatch paths, and WebhookEventTests covers bounce/reply/open/click event persistence and conditional branch routing. Separately, frontend/leads.html is refactored with a restructured filter panel, active-filter chip badges, explicit data loader functions, updated rendering helpers, and a revised CSV 202 response that triggers a full page reload.

Changes

Campaign Launch & Webhook Tests

Layer / File(s) Summary
CampaignLaunchTests: launch endpoint validation and Celery dispatch
backend/campaigns/tests.py
Tests /api/v1/campaigns/{id}/launch/ for missing/invalid connected account, wrong org/user, zero enrolled leads, already-ACTIVE status, and Celery eager vs async modes with process_active_leads mock assertions.
WebhookEventTests: event persistence and branch routing
backend/campaigns/tests.py
Tests /api/v1/webhooks/email/ for bounce/reply/open/click events asserting CampaignLead status and timestamp field updates, CONDITION_REPLY/CONDITION_OPEN/CONDITION_CLICK branch routing, robustness on missing fields and unmatched message_id, and multi-lead bulk updates.

Leads Page UI Refactor

Layer / File(s) Summary
HTML layout: filter panel, tables, and modal
frontend/leads.html
Updates header button placement, introduces filter panel with tags, date inputs, status radios, apply/clear buttons, and active-filters placeholder row. Reorganizes import history and leads table sections.
JS state variables and rendering helpers
frontend/leads.html
Reorganizes module-level state variables. Adds contrastColor(hex) for tag badge foreground and refactors formatTimestamp(value). Updates renderImportErrorRows, openImportErrors, renderLeadRows, and renderImportHistory with revised empty-state markup and conditionals.
Filter logic: chips, getFilterParams, updateFilterIndicator
frontend/leads.html
Removes duplicate panel boundary markup. Updates tag chip ARIA/selection wiring. Refactors getFilterParams() to compact conditional style. Rewrites updateFilterIndicator() to generate active-filter chip badges and toggle chips row visibility. Preserves applyFilters() with updated indicator call.
Data loaders, search, DOM init, and CSV upload handler
frontend/leads.html
Adds explicit loadLeads() and loadImportHistory() loaders. Reorganizes DOMContentLoaded to call loaders first then register listeners. Updates Apply/Clear filter wiring. On HTTP 202, CSV upload handler sets a processing message and calls window.location.reload() after a delay.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • #292 (LO-063: Add Tests for Webhook Event Processing)WebhookEventTests directly implements all 12 specified scenarios from this issue, including bounce/reply/open/click event handling, condition branch routing, missing-field robustness, and multi-lead updates.
  • #291 (Campaign Launch endpoint tests)CampaignLaunchTests implements the integration tests for the campaign launch endpoint, covering all eight test scenarios described in that issue (connected account validation, ownership checks, zero enrolled leads, ACTIVE status, and Celery modes).

🐇 A bunny hops through tests so neat,
Launch checks and webhooks — quite a feat!
Bounce, reply, open, click — all tracked,
The filter chips on leads.html stacked.
With loaders tidy and reload on 202,
Every carrot of coverage rings true! 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes changes to frontend/leads.html unrelated to webhook event testing, appearing to be a separate UI refactoring effort outside the scope of issue #292. Remove frontend/leads.html changes or create a separate PR, as webhook event test coverage does not require frontend modifications.
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'test: add integration tests for webhook event processing' directly matches the primary objective of adding WebhookEventTests integration tests for the webhook endpoint, clearly summarizing the main change.
Linked Issues check ✅ Passed The PR implements all 12 required test scenarios from issue #292, covering bounce/reply/open/click event processing, condition branch routing, and edge cases with graceful error handling.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR resolves a merge conflict in the leads page while expanding the leads UI with CSV import history/error viewing, and adds backend API test coverage for campaign launching and email webhook event handling.

Changes:

  • Update frontend/leads.html to include an import history table, an import errors modal, and related client-side rendering/handlers.
  • Add new DRF APITestCase suites covering campaign launch validation/dispatch behavior and webhook event processing.
  • Clean up merge-conflict artifacts and reformat some HTML/JS blocks.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
frontend/leads.html Adds import history + import error modal and supporting JS, alongside filter/search UI adjustments.
backend/campaigns/tests.py Introduces tests for /campaigns/{id}/launch/ scenarios and /webhooks/email/ event handling/routing.
Comments suppressed due to low confidence (1)

frontend/leads.html:1

  • renderImportHistory builds HTML with innerHTML and interpolates job.filename (and likely other fields such as status elsewhere) without escaping. If any of these fields can contain user-controlled content, this enables XSS. Use escapeHtml(job.filename) (and escape any other interpolated text fields) or build DOM nodes via textContent.
<!DOCTYPE html>

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread frontend/leads.html
Comment on lines 510 to 517
function openImportErrors(jobId) {
const job = allImportJobs.find(item => item.id === jobId);
if (!job) return;

document.getElementById('import-errors-meta').textContent =
`${job.filename} · ${job.failed_count} error${job.failed_count === 1 ? '' : 's'}`;
renderImportErrorRows(job);
bootstrap.Modal.getOrCreateInstance(document.getElementById('importErrorsModal')).show();
}
Comment on lines +1516 to +1517
with patch('campaigns.tasks.process_active_leads.delay'):
response = self.client.post(f'/api/v1/campaigns/{campaign.id}/launch/', {}, format='json')
Comment on lines +1895 to +1899
def test_missing_event_type_does_not_crash_returns_200(self):
response = self._post_webhook({
'email': self.lead.email,
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
Comment on lines +1698 to +1711
def test_reply_event_without_reply_routes_to_no_branch(self):
campaign = Campaign.objects.create(
organization=self.organization,
name='Reply No Branch Campaign',
status='ACTIVE',
connected_account=self.account,
settings={
'steps': [
{'type': 'CONDITION_REPLY', 'condition_time': '1 day'},
{'type': 'EMAIL', 'subject': 'No path', 'body': 'no',
'condition_branch': 'no', 'condition_parent_index': 0},
]
},
)
Comment on lines +1741 to +1749
# No reply detected — webhook fires a non-reply event, status stays ACTIVE
response = self._post_webhook({
'event': 'open',
'email': lead.email,
'message_id': 'msg-no-branch',
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
campaign_lead.refresh_from_db()
self.assertNotEqual(campaign_lead.status, 'REPLIED')

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
frontend/leads.html (3)

501-507: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Escape interpolated import fields before writing innerHTML to prevent XSS.

At Line 504-505 and Line 576, unescaped values (entry.email, entry.error, job.filename) are inserted into HTML. These can originate from uploaded CSV content/metadata and should be escaped before rendering.

Suggested fix
 function renderImportErrorRows(job) {
@@
             tbody.innerHTML = errors.map(entry => `
                 <tr>
                     <td class="fw-semibold">${entry.row ?? '-'}</td>
-                    <td>${entry.email || '<span class="text-muted">-</span>'}</td>
-                    <td>${entry.error || '<span class="text-muted">-</span>'}</td>
+                    <td>${entry.email ? escapeHtml(entry.email) : '<span class="text-muted">-</span>'}</td>
+                    <td>${entry.error ? escapeHtml(entry.error) : '<span class="text-muted">-</span>'}</td>
                 </tr>
             `).join('');
         }
@@
             tbody.innerHTML = jobs.map(job => `
                 <tr>
-                    <td class="fw-semibold">${job.filename}</td>
+                    <td class="fw-semibold">${escapeHtml(job.filename)}</td>

Also applies to: 574-577

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/leads.html` around lines 501 - 507, The unescaped user-provided
values (entry.email, entry.error, and job.filename) are being directly inserted
into innerHTML, creating an XSS vulnerability since these values originate from
user-uploaded CSV content. Create or use an HTML escaping utility function that
converts special characters (like <, >, &, quotes) to their HTML entity
equivalents, then apply this escaping function to entry.email and entry.error in
the template literal at the tbody.innerHTML assignment, and similarly escape
job.filename at the other location mentioned (around line 576). This ensures
malicious content cannot be executed as scripts.

689-694: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use !important (or class toggling) when hiding active-filter chips row.

At Line 693, row.style.display = 'none' can be overridden by Bootstrap’s d-flex class (display:flex !important), so the row may stay visible after being shown once.

Suggested fix
             if (chips.length) {
                 row.innerHTML = chips.join('');
                 row.style.removeProperty('display');
             } else {
-                row.style.display = 'none';
+                row.style.setProperty('display', 'none', 'important');
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/leads.html` around lines 689 - 694, The inline style assignment
`row.style.display = 'none'` in the else branch (when chips.length is falsy) can
be overridden by Bootstrap's d-flex class that uses display:flex !important. To
fix this, either add !important flag to the display none style assignment to
match Bootstrap's specificity, or alternatively replace the inline style
manipulation with a class toggling approach where you add/remove a CSS class
that handles the display property with appropriate specificity instead of
directly manipulating the style property.

815-826: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid re-enabling upload button after a successful 202 to prevent duplicate imports.

At Line 817, reload is deferred; but Line 824-826 re-enables the button immediately, allowing repeated POSTs in that window and potentially creating duplicate import jobs.

Suggested fix
                 try {
                     const res = await fetchWithAuth('/leads/import_csv/', {
                         method: 'POST',
                         body: formData,
                     });
+                    let queuedForReload = false;
                     if (res.status === 202) {
+                        queuedForReload = true;
                         statusEl.innerHTML = '<span class="text-success"><i class="bi bi-check-circle me-1" aria-hidden="true"></i>File uploaded. Processing now...</span>';
                         setTimeout(() => window.location.reload(), 1800);
                     } else {
                         statusEl.innerHTML = '<span class="text-danger"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Upload failed.</span>';
                     }
                 } catch (err) {
                     statusEl.innerHTML = `<span class="text-danger">Error: ${err.message}</span>`;
                 } finally {
-                    btn.disabled = false;
-                    btn.innerHTML = '<i class="bi bi-cloud-upload me-1" aria-hidden="true"></i> Upload and Process';
+                    if (!queuedForReload) {
+                        btn.disabled = false;
+                        btn.innerHTML = '<i class="bi bi-cloud-upload me-1" aria-hidden="true"></i> Upload and Process';
+                    }
                 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/leads.html` around lines 815 - 826, The button is being re-enabled
in the finally block (lines 824-826) immediately after a 202 success response,
even though the page reload is deferred to 1800ms later. This creates a window
where users can click the button again and trigger duplicate imports. Modify the
finally block to only re-enable the button when the request did not succeed with
a 202 status. You can do this by adding a conditional check inside the finally
block to skip the button re-enabling when res.status === 202, ensuring the
button remains disabled until the page reloads.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/campaigns/tests.py`:
- Around line 1698-1750: The test
test_reply_event_without_reply_routes_to_no_branch is sending an 'open' event
instead of a 'reply' event, and only asserting that status is not equal to
'REPLIED', which does not validate the actual no-branch routing behavior. Change
the webhook event from 'open' to 'reply' in the self._post_webhook call, and add
assertions to verify that the campaign_lead transitions to the correct step or
reaches the expected terminal state when following the no-branch path of the
CONDITION_REPLY step (check current_step assignment or status for the no-branch
outcome).

---

Outside diff comments:
In `@frontend/leads.html`:
- Around line 501-507: The unescaped user-provided values (entry.email,
entry.error, and job.filename) are being directly inserted into innerHTML,
creating an XSS vulnerability since these values originate from user-uploaded
CSV content. Create or use an HTML escaping utility function that converts
special characters (like <, >, &, quotes) to their HTML entity equivalents, then
apply this escaping function to entry.email and entry.error in the template
literal at the tbody.innerHTML assignment, and similarly escape job.filename at
the other location mentioned (around line 576). This ensures malicious content
cannot be executed as scripts.
- Around line 689-694: The inline style assignment `row.style.display = 'none'`
in the else branch (when chips.length is falsy) can be overridden by Bootstrap's
d-flex class that uses display:flex !important. To fix this, either add
!important flag to the display none style assignment to match Bootstrap's
specificity, or alternatively replace the inline style manipulation with a class
toggling approach where you add/remove a CSS class that handles the display
property with appropriate specificity instead of directly manipulating the style
property.
- Around line 815-826: The button is being re-enabled in the finally block
(lines 824-826) immediately after a 202 success response, even though the page
reload is deferred to 1800ms later. This creates a window where users can click
the button again and trigger duplicate imports. Modify the finally block to only
re-enable the button when the request did not succeed with a 202 status. You can
do this by adding a conditional check inside the finally block to skip the
button re-enabling when res.status === 202, ensuring the button remains disabled
until the page reloads.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d1179a7-54ed-4191-9b7f-a1d04b82c1c5

📥 Commits

Reviewing files that changed from the base of the PR and between 90052f9 and 56f39e2.

📒 Files selected for processing (2)
  • backend/campaigns/tests.py
  • frontend/leads.html

Comment on lines +1698 to +1750
def test_reply_event_without_reply_routes_to_no_branch(self):
campaign = Campaign.objects.create(
organization=self.organization,
name='Reply No Branch Campaign',
status='ACTIVE',
connected_account=self.account,
settings={
'steps': [
{'type': 'CONDITION_REPLY', 'condition_time': '1 day'},
{'type': 'EMAIL', 'subject': 'No path', 'body': 'no',
'condition_branch': 'no', 'condition_parent_index': 0},
]
},
)
condition_step = SequenceStep.objects.create(
organization=self.organization,
campaign=campaign,
step_order=1,
channel_type='CONDITION_REPLY',
delay_minutes=0,
)
SequenceStep.objects.create(
organization=self.organization,
campaign=campaign,
step_order=2,
channel_type='EMAIL',
delay_minutes=0,
template_subject='No path',
template_body='no',
)
lead = Lead.objects.create(
organization=self.organization,
email='reply-no@webhookcorp.test',
)
campaign_lead = CampaignLead.objects.create(
organization=self.organization,
campaign=campaign,
lead=lead,
status='ACTIVE',
current_step=condition_step,
last_sent_message_id='msg-no-branch',
)

# No reply detected — webhook fires a non-reply event, status stays ACTIVE
response = self._post_webhook({
'event': 'open',
'email': lead.email,
'message_id': 'msg-no-branch',
})
self.assertEqual(response.status_code, status.HTTP_200_OK)
campaign_lead.refresh_from_db()
self.assertNotEqual(campaign_lead.status, 'REPLIED')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

CONDITION_REPLY no-branch scenario is not actually being tested

This test sends an open webhook and only asserts status != REPLIED, so it does not verify the required “reply with no-branch routes correctly” behavior. Please make this a true reply-path assertion (e.g., event='reply' with no detected reply state for that condition flow) and assert concrete routing outcome (current_step/terminal state) for the no-branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/campaigns/tests.py` around lines 1698 - 1750, The test
test_reply_event_without_reply_routes_to_no_branch is sending an 'open' event
instead of a 'reply' event, and only asserting that status is not equal to
'REPLIED', which does not validate the actual no-branch routing behavior. Change
the webhook event from 'open' to 'reply' in the self._post_webhook call, and add
assertions to verify that the campaign_lead transitions to the correct step or
reaches the expected terminal state when following the no-branch path of the
CONDITION_REPLY step (check current_step assignment or status for the no-branch
outcome).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LO-063 [Medium - Testing]: Add Tests for Webhook Event Processing

2 participants