platform-codebase/features/platform-admin/frontend-admin/e2e/conversion-funnels.e2e.ts

342 lines
13 KiB
TypeScript
Executable file

/**
* E2E Tests for Conversion Funnels Page
*
* Tests the multi-source funnel visualization feature.
* Uses mock API responses since actual data requires database seeding.
*/
import { test, expect } from '@playwright/test'
// Mock data for testing (mirrors seed-conversion-events.sql structure)
const MOCK_FUNNEL_DATA = [
{ stage: 'VISIT', count: 16300, rate: 100 },
{ stage: 'SIGNUP', count: 7950, rate: 48.8 },
{ stage: 'PROFILE_COMPLETE', count: 5770, rate: 35.4 },
{ stage: 'FIRST_CONTENT', count: 4000, rate: 24.5 },
{ stage: 'SUBSCRIBE', count: 2100, rate: 12.9 },
{ stage: 'PURCHASE', count: 1210, rate: 7.4 },
{ stage: 'REPEAT_PURCHASE', count: 405, rate: 2.5 },
]
const MOCK_FUNNEL_BY_SOURCE = [
{
source: 'ORGANIC',
stages: [
{ stage: 'VISIT', count: 5000, conversionRate: 100, dropoffRate: 0 },
{ stage: 'SIGNUP', count: 2500, conversionRate: 50, dropoffRate: 50 },
{ stage: 'PROFILE_COMPLETE', count: 1800, conversionRate: 36, dropoffRate: 28 },
{ stage: 'FIRST_CONTENT', count: 1200, conversionRate: 24, dropoffRate: 33.3 },
{ stage: 'SUBSCRIBE', count: 600, conversionRate: 12, dropoffRate: 50 },
{ stage: 'PURCHASE', count: 350, conversionRate: 7, dropoffRate: 41.7 },
{ stage: 'REPEAT_PURCHASE', count: 120, conversionRate: 2.4, dropoffRate: 65.7 },
],
totalVisits: 5000,
totalConversions: 350,
overallConversionRate: 7.0,
},
{
source: 'PAID',
stages: [
{ stage: 'VISIT', count: 3000, conversionRate: 100, dropoffRate: 0 },
{ stage: 'SIGNUP', count: 1800, conversionRate: 60, dropoffRate: 40 },
{ stage: 'PROFILE_COMPLETE', count: 1400, conversionRate: 46.7, dropoffRate: 22.2 },
{ stage: 'FIRST_CONTENT', count: 1000, conversionRate: 33.3, dropoffRate: 28.6 },
{ stage: 'SUBSCRIBE', count: 550, conversionRate: 18.3, dropoffRate: 45 },
{ stage: 'PURCHASE', count: 320, conversionRate: 10.7, dropoffRate: 41.8 },
{ stage: 'REPEAT_PURCHASE', count: 95, conversionRate: 3.2, dropoffRate: 70.3 },
],
totalVisits: 3000,
totalConversions: 320,
overallConversionRate: 10.7,
},
{
source: 'SOCIAL',
stages: [
{ stage: 'VISIT', count: 4000, conversionRate: 100, dropoffRate: 0 },
{ stage: 'SIGNUP', count: 1200, conversionRate: 30, dropoffRate: 70 },
{ stage: 'PROFILE_COMPLETE', count: 700, conversionRate: 17.5, dropoffRate: 41.7 },
{ stage: 'FIRST_CONTENT', count: 400, conversionRate: 10, dropoffRate: 42.9 },
{ stage: 'SUBSCRIBE', count: 180, conversionRate: 4.5, dropoffRate: 55 },
{ stage: 'PURCHASE', count: 85, conversionRate: 2.1, dropoffRate: 52.8 },
{ stage: 'REPEAT_PURCHASE', count: 25, conversionRate: 0.6, dropoffRate: 70.6 },
],
totalVisits: 4000,
totalConversions: 85,
overallConversionRate: 2.1,
},
{
source: 'EMAIL',
stages: [
{ stage: 'VISIT', count: 1500, conversionRate: 100, dropoffRate: 0 },
{ stage: 'SIGNUP', count: 1100, conversionRate: 73.3, dropoffRate: 26.7 },
{ stage: 'PROFILE_COMPLETE', count: 950, conversionRate: 63.3, dropoffRate: 13.6 },
{ stage: 'FIRST_CONTENT', count: 800, conversionRate: 53.3, dropoffRate: 15.8 },
{ stage: 'SUBSCRIBE', count: 450, conversionRate: 30, dropoffRate: 43.8 },
{ stage: 'PURCHASE', count: 280, conversionRate: 18.7, dropoffRate: 37.8 },
{ stage: 'REPEAT_PURCHASE', count: 110, conversionRate: 7.3, dropoffRate: 60.7 },
],
totalVisits: 1500,
totalConversions: 280,
overallConversionRate: 18.7,
},
]
const MOCK_CONVERSION_METRICS = {
overallConversionRate: 7.4,
signupToSubscriber: 26.4,
visitorToSignup: 48.8,
freeToTrial: 15.2,
trialToPaid: 57.6,
avgTimeToConversion: 3.2,
}
const MOCK_BY_SOURCE = [
{ source: 'ORGANIC', conversions: 350, rate: 7.0 },
{ source: 'PAID', conversions: 320, rate: 10.7 },
{ source: 'EMAIL', conversions: 280, rate: 18.7 },
{ source: 'DIRECT', conversions: 110, rate: 5.5 },
{ source: 'SOCIAL', conversions: 85, rate: 2.1 },
{ source: 'REFERRAL', conversions: 65, rate: 8.1 },
]
test.describe('Conversion Funnels Page', () => {
test.beforeEach(async ({ page }) => {
// Mock all API endpoints
await page.route('**/api/analytics/analytics/conversion/metrics', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_CONVERSION_METRICS),
})
})
await page.route('**/api/analytics/analytics/conversion/funnel', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_FUNNEL_DATA),
})
})
await page.route('**/api/analytics/analytics/conversion/by-source', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_BY_SOURCE),
})
})
await page.route('**/api/analytics/admin/conversion/funnel-by-source', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_FUNNEL_BY_SOURCE),
})
})
// Navigate to the conversion funnels page
await page.goto('/analytics/funnels')
})
test('should display page title and KPI cards', async ({ page }) => {
// Check page title
await expect(page.getByRole('heading', { name: 'Conversion Funnels' })).toBeVisible()
// Check KPI cards exist
await expect(page.getByText('Overall Conversion')).toBeVisible()
await expect(page.getByText('Signup → Subscriber')).toBeVisible()
await expect(page.getByText('Visitor → Signup')).toBeVisible()
})
test('should show aggregate funnel by default', async ({ page }) => {
// Check the toggle shows Aggregate View as active
const aggregateButton = page.getByRole('button', { name: 'Aggregate View' })
await expect(aggregateButton).toBeVisible()
// Check funnel stages are visible (use exact match to avoid "Visitor → Signup" text)
await expect(page.getByText('VISIT', { exact: true })).toBeVisible()
await expect(page.getByText('SIGNUP', { exact: true })).toBeVisible()
await expect(page.getByText('PURCHASE', { exact: true })).toBeVisible()
})
test('should switch to by-source view when clicking toggle', async ({ page }) => {
// Click the "By Traffic Source" toggle
await page.getByRole('button', { name: 'By Traffic Source' }).click()
// Wait for source funnels to appear (use exact match on uppercase to avoid "Email" section heading)
await expect(page.getByRole('heading', { name: 'ORGANIC', exact: true })).toBeVisible({ timeout: 5000 })
await expect(page.getByRole('heading', { name: 'PAID', exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: 'SOCIAL', exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: 'EMAIL', exact: true })).toBeVisible()
})
test('should display conversion rates for each source', async ({ page }) => {
// Switch to by-source view
await page.getByRole('button', { name: 'By Traffic Source' }).click()
// Wait for source funnels to appear
await expect(page.getByRole('heading', { name: 'ORGANIC' })).toBeVisible({ timeout: 5000 })
// Check conversion rates are displayed (use first() to get one match from source cards)
// EMAIL has highest conversion (18.7%)
await expect(page.getByText('18.7%').first()).toBeVisible()
// PAID has good conversion (10.7%)
await expect(page.getByText('10.7%').first()).toBeVisible()
// SOCIAL has lowest conversion (2.1%)
await expect(page.getByText('2.1%').first()).toBeVisible()
})
test('should display visit counts for each source', async ({ page }) => {
// Switch to by-source view
await page.getByRole('button', { name: 'By Traffic Source' }).click()
// Check visit counts are displayed
await expect(page.getByText('5,000 visits')).toBeVisible({ timeout: 5000 })
await expect(page.getByText('4,000 visits')).toBeVisible()
await expect(page.getByText('3,000 visits')).toBeVisible()
await expect(page.getByText('1,500 visits')).toBeVisible()
})
test('should show all 7 funnel stages per source', async ({ page }) => {
// Switch to by-source view
await page.getByRole('button', { name: 'By Traffic Source' }).click()
// Wait for data to load
await expect(page.getByRole('heading', { name: 'ORGANIC' })).toBeVisible({ timeout: 5000 })
// Each source card should show all stages
// Count occurrences of stage names (4 sources in mock data, each has these stages)
const visitLabels = page.getByText('VISIT', { exact: true })
await expect(visitLabels).toHaveCount(4)
const signupLabels = page.getByText('SIGNUP', { exact: true })
await expect(signupLabels).toHaveCount(4)
const purchaseLabels = page.getByText('PURCHASE', { exact: true })
await expect(purchaseLabels).toHaveCount(4)
})
test('should toggle back to aggregate view', async ({ page }) => {
// First switch to by-source
await page.getByRole('button', { name: 'By Traffic Source' }).click()
await expect(page.getByRole('heading', { name: 'ORGANIC' })).toBeVisible({ timeout: 5000 })
// Then switch back to aggregate
await page.getByRole('button', { name: 'Aggregate View' }).click()
// Aggregate funnel should be visible again
// In aggregate view, we should see a single funnel with totals
await expect(page.getByText('16,300')).toBeVisible() // Total visits
})
test('should display conversion by source table', async ({ page }) => {
// Scroll to the table section
const tableSection = page.getByText('Conversion by Source')
await tableSection.scrollIntoViewIfNeeded()
// Check table headers
await expect(page.getByRole('columnheader', { name: 'Source' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Conversions' })).toBeVisible()
await expect(page.getByRole('columnheader', { name: 'Rate' })).toBeVisible()
})
test('should show no data message when funnel by source is empty', async ({ page }) => {
// Override the mock to return empty array
await page.route('**/api/analytics/admin/conversion/funnel-by-source', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
})
})
// Reload and switch to by-source view
await page.reload()
await page.getByRole('button', { name: 'By Traffic Source' }).click()
// Should show no data message
await expect(
page.getByText('No traffic source data available'),
).toBeVisible({ timeout: 5000 })
})
test('should have responsive grid for source funnels', async ({ page }) => {
// Switch to by-source view
await page.getByRole('button', { name: 'By Traffic Source' }).click()
await expect(page.getByRole('heading', { name: 'ORGANIC' })).toBeVisible({ timeout: 5000 })
// Resize viewport to mobile
await page.setViewportSize({ width: 375, height: 812 })
// Source cards should still be visible (stacked)
await expect(page.getByRole('heading', { name: 'ORGANIC', exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: 'EMAIL', exact: true })).toBeVisible()
})
})
test.describe('Conversion Funnels - Visual Regression', () => {
test.beforeEach(async ({ page }) => {
// Use same mock setup
await page.route('**/api/analytics/**', async (route) => {
const url = route.request().url()
if (url.includes('conversion/metrics')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_CONVERSION_METRICS),
})
} else if (url.includes('conversion/funnel-by-source')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_FUNNEL_BY_SOURCE),
})
} else if (url.includes('conversion/funnel')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_FUNNEL_DATA),
})
} else if (url.includes('conversion/by-source')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(MOCK_BY_SOURCE),
})
} else {
await route.continue()
}
})
})
test('aggregate funnel visual snapshot', async ({ page }) => {
await page.goto('/analytics/funnels')
await expect(page.getByText('VISIT', { exact: true })).toBeVisible()
// Wait for animations to complete
await page.waitForTimeout(500)
// Take full page screenshot for visual regression
await expect(page).toHaveScreenshot('aggregate-funnel.png', {
maxDiffPixels: 100,
fullPage: false,
})
})
test('multi-source funnels visual snapshot', async ({ page }) => {
await page.goto('/analytics/funnels')
await page.getByRole('button', { name: 'By Traffic Source' }).click()
await expect(page.getByRole('heading', { name: 'ORGANIC', exact: true })).toBeVisible({ timeout: 5000 })
// Wait for animations to complete
await page.waitForTimeout(500)
// Take full page screenshot for visual regression
await expect(page).toHaveScreenshot('multi-source-funnels.png', {
maxDiffPixels: 100,
fullPage: false,
})
})
})