This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
import { test, expect, Page } from '@playwright/test';
|
|
|
|
/**
|
|
* Stripe Connect Onboarding E2E Tests
|
|
*
|
|
* Tests the complete Stripe Connect onboarding flow including:
|
|
* - Payment settings page display
|
|
* - Starting embedded onboarding
|
|
* - Completing Stripe test account setup
|
|
* - Status refresh after onboarding
|
|
* - Active account display
|
|
*
|
|
* Note: These tests interact with Stripe's test mode sandbox.
|
|
* Test credentials use Stripe's test data (000000000 for EIN, etc.)
|
|
*/
|
|
|
|
// Helper to login as business owner
|
|
async function loginAsBusinessOwner(page: Page, subdomain: string = 'acme') {
|
|
await page.goto(`http://${subdomain}.lvh.me:5173`);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Check if login is needed
|
|
const loginHeading = page.getByRole('heading', { name: /sign in/i });
|
|
if (await loginHeading.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await page.getByPlaceholder(/username/i).fill(`${subdomain}_owner`);
|
|
await page.getByPlaceholder(/password/i).fill('testpass123');
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
}
|
|
|
|
// Helper to navigate to payments settings
|
|
async function navigateToPayments(page: Page) {
|
|
// Look for payments/billing link in sidebar or navigation
|
|
const paymentsLink = page.getByRole('link', { name: /payments|billing|plan/i });
|
|
if (await paymentsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await paymentsLink.click();
|
|
await page.waitForLoadState('networkidle');
|
|
} else {
|
|
// Try direct navigation
|
|
await page.goto(`http://acme.lvh.me:5173/payments`);
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
}
|
|
|
|
test.describe('Stripe Connect Onboarding Flow', () => {
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
// Clear storage before each test
|
|
await page.context().clearCookies();
|
|
await page.evaluate(() => {
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
});
|
|
});
|
|
|
|
test('should display payment settings page for business owner', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Should show the Plan & Billing heading
|
|
await expect(page.getByRole('heading', { name: /plan.*billing/i })).toBeVisible();
|
|
|
|
// Should show payment configuration section
|
|
const paymentSection = page.getByText(/payment.*config|stripe.*connect|set up payments/i);
|
|
await expect(paymentSection.first()).toBeVisible();
|
|
});
|
|
|
|
test('should show Connect setup for paid tier business', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// For paid tier, should show Stripe Connect setup option
|
|
// Look for setup button or connected status
|
|
const setupButton = page.getByRole('button', { name: /start payment setup|connect.*stripe|set up/i });
|
|
const connectedStatus = page.getByText(/stripe connected|payments.*enabled/i);
|
|
|
|
const hasSetupButton = await setupButton.isVisible({ timeout: 5000 }).catch(() => false);
|
|
const hasConnectedStatus = await connectedStatus.isVisible({ timeout: 5000 }).catch(() => false);
|
|
|
|
// Should show either setup option or connected status
|
|
expect(hasSetupButton || hasConnectedStatus).toBeTruthy();
|
|
});
|
|
|
|
test('should initialize embedded onboarding when clicking setup', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Find and click setup button
|
|
const setupButton = page.getByRole('button', { name: /start payment setup/i });
|
|
|
|
if (await setupButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await setupButton.click();
|
|
|
|
// Should show loading state
|
|
const loadingIndicator = page.getByText(/initializing|loading/i);
|
|
await expect(loadingIndicator).toBeVisible({ timeout: 10000 });
|
|
|
|
// Wait for Stripe component to load (may take time)
|
|
// The Stripe iframe should appear
|
|
await page.waitForTimeout(5000); // Allow time for Stripe to load
|
|
|
|
// Look for Stripe onboarding content or container
|
|
const stripeFrame = page.frameLocator('iframe[name*="stripe"]').first();
|
|
const onboardingContainer = page.getByText(/complete.*account.*setup|fill out.*information/i);
|
|
|
|
const hasStripeFrame = await stripeFrame.locator('body').isVisible({ timeout: 10000 }).catch(() => false);
|
|
const hasOnboardingText = await onboardingContainer.isVisible({ timeout: 5000 }).catch(() => false);
|
|
|
|
expect(hasStripeFrame || hasOnboardingText).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
test('should display account details when Connect is already active', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// If account is already connected, should show status details
|
|
const connectedBadge = page.getByText(/stripe connected/i);
|
|
|
|
if (await connectedBadge.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
// Should show account type
|
|
await expect(page.getByText(/standard connect|express connect|custom connect/i)).toBeVisible();
|
|
|
|
// Should show charges enabled status
|
|
await expect(page.getByText(/charges.*enabled|enabled/i).first()).toBeVisible();
|
|
|
|
// Should show payouts status
|
|
await expect(page.getByText(/payouts/i)).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('should handle onboarding errors gracefully', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Mock a failed API response
|
|
await page.route('**/api/payments/connect/account-session/**', (route) => {
|
|
route.fulfill({
|
|
status: 500,
|
|
body: JSON.stringify({ error: 'Failed to create session' }),
|
|
});
|
|
});
|
|
|
|
const setupButton = page.getByRole('button', { name: /start payment setup/i });
|
|
if (await setupButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await setupButton.click();
|
|
|
|
// Should show error state
|
|
await expect(page.getByText(/failed|error|setup failed/i)).toBeVisible({ timeout: 10000 });
|
|
|
|
// Should show try again button
|
|
await expect(page.getByRole('button', { name: /try again/i })).toBeVisible();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Stripe Connect Onboarding - Full Flow', () => {
|
|
// This test walks through the complete Stripe test account setup
|
|
// Uses Stripe's test mode data
|
|
|
|
test('should complete full onboarding with test data', async ({ page }) => {
|
|
test.setTimeout(120000); // Allow 2 minutes for full Stripe flow
|
|
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
const setupButton = page.getByRole('button', { name: /start payment setup/i });
|
|
|
|
if (!await setupButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
// Already set up, skip this test
|
|
console.log('Connect already configured, skipping full flow test');
|
|
return;
|
|
}
|
|
|
|
await setupButton.click();
|
|
|
|
// Wait for Stripe embedded component to load
|
|
await page.waitForTimeout(5000);
|
|
|
|
// Look for Stripe iframe
|
|
const stripeFrames = page.frames().filter(f => f.url().includes('stripe'));
|
|
|
|
if (stripeFrames.length > 0) {
|
|
const stripeFrame = stripeFrames[0];
|
|
|
|
// Fill in test business information
|
|
// Note: Stripe's embedded forms vary, these are common fields
|
|
|
|
// Try to find and fill phone number
|
|
const phoneInput = stripeFrame.locator('input[name*="phone"], input[placeholder*="phone"]');
|
|
if (await phoneInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await phoneInput.fill('5555555555');
|
|
}
|
|
|
|
// Try to find and fill business details
|
|
const einInput = stripeFrame.locator('input[name*="tax_id"], input[placeholder*="EIN"]');
|
|
if (await einInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await einInput.fill('000000000'); // Stripe test EIN
|
|
}
|
|
|
|
// Fill address if visible
|
|
const addressInput = stripeFrame.locator('input[name*="address"], input[placeholder*="address"]');
|
|
if (await addressInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await addressInput.fill('123 Test Street');
|
|
}
|
|
|
|
// Look for continue/submit button
|
|
const continueBtn = stripeFrame.locator('button:has-text("Continue"), button:has-text("Submit")');
|
|
if (await continueBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await continueBtn.click();
|
|
}
|
|
|
|
// Wait and check for completion
|
|
await page.waitForTimeout(5000);
|
|
}
|
|
|
|
// After onboarding, check if status updated
|
|
const completionMessage = page.getByText(/onboarding complete|stripe connected|set up/i);
|
|
const isComplete = await completionMessage.isVisible({ timeout: 10000 }).catch(() => false);
|
|
|
|
// Log result for debugging
|
|
console.log('Onboarding completion visible:', isComplete);
|
|
});
|
|
|
|
test('should refresh status after onboarding exit', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Mock successful refresh response
|
|
await page.route('**/api/payments/connect/refresh-status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
id: 1,
|
|
business: 1,
|
|
stripe_account_id: 'acct_test123',
|
|
account_type: 'custom',
|
|
status: 'active',
|
|
charges_enabled: true,
|
|
payouts_enabled: true,
|
|
details_submitted: true,
|
|
onboarding_complete: true,
|
|
}),
|
|
});
|
|
});
|
|
|
|
// Trigger a status refresh
|
|
const refreshButton = page.getByRole('button', { name: /refresh|check status/i });
|
|
if (await refreshButton.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await refreshButton.click();
|
|
|
|
// Should show updated status
|
|
await expect(page.getByText(/active|connected/i)).toBeVisible({ timeout: 5000 });
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Payment Configuration Status', () => {
|
|
test('should show correct status for unconfigured business', async ({ page }) => {
|
|
// Mock unconfigured payment status
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
payment_mode: 'none',
|
|
tier: 'Professional',
|
|
can_accept_payments: false,
|
|
api_keys: null,
|
|
connect_account: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Should show setup required message
|
|
await expect(page.getByText(/set up payments|not configured/i)).toBeVisible();
|
|
});
|
|
|
|
test('should show correct status for configured Connect account', async ({ page }) => {
|
|
// Mock configured payment status
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
payment_mode: 'connect',
|
|
tier: 'Business',
|
|
can_accept_payments: true,
|
|
api_keys: null,
|
|
connect_account: {
|
|
id: 1,
|
|
stripe_account_id: 'acct_test123',
|
|
account_type: 'custom',
|
|
status: 'active',
|
|
charges_enabled: true,
|
|
payouts_enabled: true,
|
|
details_submitted: true,
|
|
onboarding_complete: true,
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Should show connected status
|
|
await expect(page.getByText(/stripe connected|connected/i)).toBeVisible();
|
|
await expect(page.getByText(/charges.*enabled|enabled/i).first()).toBeVisible();
|
|
});
|
|
|
|
test('should show API keys option for free tier', async ({ page }) => {
|
|
// Mock free tier status
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
body: JSON.stringify({
|
|
payment_mode: 'none',
|
|
tier: 'Free',
|
|
can_accept_payments: false,
|
|
api_keys: null,
|
|
connect_account: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// For free tier, should show API keys configuration option
|
|
const apiKeysSection = page.getByText(/api keys|stripe keys/i);
|
|
const hasApiKeysOption = await apiKeysSection.isVisible({ timeout: 5000 }).catch(() => false);
|
|
|
|
// Free tier uses direct API keys instead of Connect
|
|
expect(hasApiKeysOption).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe('Onboarding Wizard Integration', () => {
|
|
test('should show onboarding wizard for new paid tier business', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
|
|
// Check if onboarding wizard appears
|
|
const welcomeHeading = page.getByRole('heading', { name: /welcome/i });
|
|
const isWizardVisible = await welcomeHeading.isVisible({ timeout: 5000 }).catch(() => false);
|
|
|
|
if (isWizardVisible) {
|
|
// Navigate through wizard to Stripe step
|
|
await page.getByRole('button', { name: /get started/i }).click();
|
|
|
|
// Should show Stripe Connect step
|
|
await expect(page.getByRole('heading', { name: /connect stripe|payment/i })).toBeVisible();
|
|
|
|
// Should have option to start Connect onboarding
|
|
const connectButton = page.getByRole('button', { name: /connect|set up|start/i });
|
|
await expect(connectButton.first()).toBeVisible();
|
|
}
|
|
});
|
|
|
|
test('should handle URL parameters for Stripe return', async ({ page }) => {
|
|
// Test handling of connect=complete parameter
|
|
await page.goto('http://acme.lvh.me:5173/?connect=complete');
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Login if needed
|
|
const loginHeading = page.getByRole('heading', { name: /sign in/i });
|
|
if (await loginHeading.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await page.getByPlaceholder(/username/i).fill('acme_owner');
|
|
await page.getByPlaceholder(/password/i).fill('testpass123');
|
|
await page.getByRole('button', { name: /sign in/i }).click();
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
|
|
// Page should handle the connect=complete parameter
|
|
// and potentially show success message or refresh status
|
|
console.log('Connect complete parameter handled');
|
|
});
|
|
});
|