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>
979 lines
31 KiB
TypeScript
979 lines
31 KiB
TypeScript
import { test, expect, Page } from '@playwright/test';
|
|
|
|
/**
|
|
* Transaction Analytics and Refund E2E Tests
|
|
*
|
|
* Tests the complete transaction analytics and refund flow including:
|
|
* - Transaction list display
|
|
* - Transaction filtering
|
|
* - Transaction detail modal
|
|
* - Refund functionality
|
|
* - Export functionality
|
|
* - Payouts tab
|
|
*/
|
|
|
|
// 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 page
|
|
async function navigateToPayments(page: Page, subdomain: string = 'acme') {
|
|
const paymentsLink = page.getByRole('link', { name: /payments|billing/i });
|
|
if (await paymentsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await paymentsLink.click();
|
|
await page.waitForLoadState('networkidle');
|
|
} else {
|
|
await page.goto(`http://${subdomain}.lvh.me:5173/payments`);
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
}
|
|
|
|
// Mock transaction data
|
|
const mockTransactions = {
|
|
count: 3,
|
|
total_pages: 1,
|
|
results: [
|
|
{
|
|
id: 1,
|
|
stripe_payment_intent_id: 'pi_test_succeeded_123',
|
|
stripe_charge_id: 'ch_test_succeeded_123',
|
|
transaction_type: 'payment',
|
|
status: 'succeeded',
|
|
amount: 5000,
|
|
amount_display: '$50.00',
|
|
application_fee_amount: 150,
|
|
fee_display: '$1.50',
|
|
net_amount: 4850,
|
|
currency: 'usd',
|
|
customer_name: 'Test Customer',
|
|
customer_email: 'test@example.com',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
{
|
|
id: 2,
|
|
stripe_payment_intent_id: 'pi_test_refunded_456',
|
|
stripe_charge_id: 'ch_test_refunded_456',
|
|
transaction_type: 'payment',
|
|
status: 'refunded',
|
|
amount: 10000,
|
|
amount_display: '$100.00',
|
|
application_fee_amount: 300,
|
|
fee_display: '$3.00',
|
|
net_amount: 9700,
|
|
currency: 'usd',
|
|
customer_name: 'Another Customer',
|
|
customer_email: 'another@example.com',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
{
|
|
id: 3,
|
|
stripe_payment_intent_id: 'pi_test_partial_789',
|
|
stripe_charge_id: 'ch_test_partial_789',
|
|
transaction_type: 'payment',
|
|
status: 'partially_refunded',
|
|
amount: 7500,
|
|
amount_display: '$75.00',
|
|
application_fee_amount: 225,
|
|
fee_display: '$2.25',
|
|
net_amount: 7275,
|
|
currency: 'usd',
|
|
customer_name: 'Partial Customer',
|
|
customer_email: 'partial@example.com',
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
],
|
|
};
|
|
|
|
// Mock transaction detail with refund capability
|
|
const mockTransactionDetail = {
|
|
...mockTransactions.results[0],
|
|
can_refund: true,
|
|
refundable_amount: 5000,
|
|
total_refunded: 0,
|
|
refunds: [],
|
|
payment_method_info: {
|
|
type: 'card',
|
|
brand: 'Visa',
|
|
last4: '4242',
|
|
exp_month: 12,
|
|
exp_year: 2025,
|
|
funding: 'credit',
|
|
},
|
|
description: 'Payment for appointment',
|
|
stripe_data: {},
|
|
};
|
|
|
|
// Mock transaction summary
|
|
const mockSummary = {
|
|
total_transactions: 10,
|
|
total_volume: 50000,
|
|
total_volume_display: '$500.00',
|
|
total_fees: 1500,
|
|
total_fees_display: '$15.00',
|
|
net_revenue: 48500,
|
|
net_revenue_display: '$485.00',
|
|
successful_transactions: 8,
|
|
failed_transactions: 1,
|
|
refunded_transactions: 1,
|
|
average_transaction: 5000,
|
|
average_transaction_display: '$50.00',
|
|
};
|
|
|
|
// Mock balance
|
|
const mockBalance = {
|
|
available: [{ amount: 150000, currency: 'usd', amount_display: '$1,500.00' }],
|
|
pending: [{ amount: 25000, currency: 'usd', amount_display: '$250.00' }],
|
|
available_total: 150000,
|
|
pending_total: 25000,
|
|
};
|
|
|
|
// Mock payment config (can accept payments)
|
|
const mockPaymentConfig = {
|
|
payment_mode: 'connect',
|
|
tier: 'Professional',
|
|
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,
|
|
},
|
|
};
|
|
|
|
test.describe('Transaction Analytics - Overview Tab', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Clear storage
|
|
await page.context().clearCookies();
|
|
|
|
// Mock API responses
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockPaymentConfig),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
if (route.request().url().includes('/summary')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSummary),
|
|
});
|
|
} else {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactions),
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/balance/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockBalance),
|
|
});
|
|
});
|
|
});
|
|
|
|
test('should display overview tab with summary cards', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Should show the Payments & Analytics heading
|
|
await expect(page.getByRole('heading', { name: /payments.*analytics/i })).toBeVisible();
|
|
|
|
// Should show overview tab by default
|
|
await expect(page.getByRole('button', { name: /overview/i })).toBeVisible();
|
|
|
|
// Wait for data to load
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Should show summary cards
|
|
await expect(page.getByText(/total revenue/i)).toBeVisible();
|
|
await expect(page.getByText(/available balance/i)).toBeVisible();
|
|
await expect(page.getByText(/success rate/i)).toBeVisible();
|
|
});
|
|
|
|
test('should show recent transactions in overview', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Wait for transactions to load
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Should show recent transactions section
|
|
await expect(page.getByText(/recent transactions/i)).toBeVisible();
|
|
|
|
// Should show View All link
|
|
await expect(page.getByText(/view all/i)).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Transaction List and Filtering', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockPaymentConfig),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
const url = route.request().url();
|
|
if (url.includes('/summary')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSummary),
|
|
});
|
|
} else if (url.includes('/balance')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockBalance),
|
|
});
|
|
} else if (url.includes('status=succeeded')) {
|
|
// Filtered by succeeded status
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
count: 1,
|
|
total_pages: 1,
|
|
results: [mockTransactions.results[0]],
|
|
}),
|
|
});
|
|
} else {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactions),
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
test('should navigate to transactions tab and display list', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Transactions tab
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show transaction table headers
|
|
await expect(page.getByText('Customer').first()).toBeVisible();
|
|
await expect(page.getByText('Amount').first()).toBeVisible();
|
|
await expect(page.getByText('Status').first()).toBeVisible();
|
|
});
|
|
|
|
test('should show status filters', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Transactions tab
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
|
|
// Should show status filter dropdown
|
|
const statusFilter = page.locator('select').filter({ hasText: /all statuses/i });
|
|
await expect(statusFilter).toBeVisible();
|
|
|
|
// Should have filter options
|
|
await statusFilter.click();
|
|
await expect(page.getByRole('option', { name: /succeeded/i })).toBeVisible();
|
|
await expect(page.getByRole('option', { name: /pending/i })).toBeVisible();
|
|
await expect(page.getByRole('option', { name: /failed/i })).toBeVisible();
|
|
await expect(page.getByRole('option', { name: /refunded/i })).toBeVisible();
|
|
});
|
|
|
|
test('should show transaction type filters', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Transactions tab
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
|
|
// Should show type filter dropdown
|
|
const typeFilter = page.locator('select').filter({ hasText: /all types/i });
|
|
await expect(typeFilter).toBeVisible();
|
|
});
|
|
|
|
test('should show date range inputs', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Transactions tab
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
|
|
// Should show date inputs
|
|
const dateInputs = page.locator('input[type="date"]');
|
|
await expect(dateInputs.first()).toBeVisible();
|
|
});
|
|
|
|
test('should show refresh button', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Transactions tab
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
|
|
// Should show refresh button
|
|
await expect(page.getByRole('button', { name: /refresh/i })).toBeVisible();
|
|
});
|
|
|
|
test('should display status badges correctly', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Transactions tab
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show different status badges
|
|
await expect(page.getByText('Succeeded').first()).toBeVisible();
|
|
await expect(page.getByText('Refunded').first()).toBeVisible();
|
|
await expect(page.getByText('Partially Refunded').first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Transaction Detail Modal', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockPaymentConfig),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
const url = route.request().url();
|
|
if (url.match(/\/transactions\/\d+\/?$/)) {
|
|
// Single transaction detail
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactionDetail),
|
|
});
|
|
} else if (url.includes('/summary')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSummary),
|
|
});
|
|
} else if (url.includes('/balance')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockBalance),
|
|
});
|
|
} else {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactions),
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
test('should open transaction detail modal when clicking row', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Transactions tab
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click on a transaction row (click the View button)
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Modal should open
|
|
await expect(page.getByRole('heading', { name: /transaction details/i })).toBeVisible();
|
|
});
|
|
|
|
test('should display customer information in modal', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show customer section
|
|
await expect(page.getByText(/customer/i)).toBeVisible();
|
|
});
|
|
|
|
test('should display amount breakdown in modal', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show amount breakdown
|
|
await expect(page.getByText(/gross amount/i)).toBeVisible();
|
|
await expect(page.getByText(/platform fee/i)).toBeVisible();
|
|
});
|
|
|
|
test('should show Issue Refund button for refundable transactions', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show Issue Refund button
|
|
await expect(page.getByRole('button', { name: /issue refund/i })).toBeVisible();
|
|
});
|
|
|
|
test('should close modal when clicking X button', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Close modal
|
|
const closeButton = page.locator('[role="dialog"]').getByRole('button').first();
|
|
if (await closeButton.isVisible()) {
|
|
await closeButton.click();
|
|
} else {
|
|
// Alternative: click X button by finding it
|
|
await page.keyboard.press('Escape');
|
|
}
|
|
|
|
await page.waitForTimeout(300);
|
|
|
|
// Modal should be closed
|
|
await expect(page.getByRole('heading', { name: /transaction details/i })).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Refund Functionality', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockPaymentConfig),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
const url = route.request().url();
|
|
if (url.match(/\/transactions\/\d+\/refund\/?$/)) {
|
|
// Refund endpoint
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
success: true,
|
|
refund_id: 're_test123',
|
|
amount: 5000,
|
|
amount_display: '$50.00',
|
|
status: 'succeeded',
|
|
reason: 'requested_by_customer',
|
|
transaction_status: 'refunded',
|
|
}),
|
|
});
|
|
} else if (url.match(/\/transactions\/\d+\/?$/)) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactionDetail),
|
|
});
|
|
} else if (url.includes('/summary')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSummary),
|
|
});
|
|
} else if (url.includes('/balance')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockBalance),
|
|
});
|
|
} else {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactions),
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
test('should show refund form when clicking Issue Refund', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click Issue Refund
|
|
await page.getByRole('button', { name: /issue refund/i }).click();
|
|
|
|
// Should show refund form
|
|
await expect(page.getByText(/full refund/i)).toBeVisible();
|
|
await expect(page.getByText(/partial refund/i)).toBeVisible();
|
|
await expect(page.getByText(/refund reason/i)).toBeVisible();
|
|
});
|
|
|
|
test('should show refund reason options', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click Issue Refund
|
|
await page.getByRole('button', { name: /issue refund/i }).click();
|
|
|
|
// Should show reason dropdown
|
|
const reasonSelect = page.locator('select').filter({ hasText: /requested by customer/i });
|
|
await expect(reasonSelect).toBeVisible();
|
|
});
|
|
|
|
test('should show partial refund amount input when partial selected', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click Issue Refund
|
|
await page.getByRole('button', { name: /issue refund/i }).click();
|
|
|
|
// Select partial refund
|
|
await page.getByLabel(/partial refund/i).check();
|
|
|
|
// Should show amount input
|
|
await expect(page.locator('input[type="number"]')).toBeVisible();
|
|
});
|
|
|
|
test('should have Confirm Refund and Cancel buttons', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click Issue Refund
|
|
await page.getByRole('button', { name: /issue refund/i }).click();
|
|
|
|
// Should show confirm and cancel buttons
|
|
await expect(page.getByRole('button', { name: /confirm refund/i })).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible();
|
|
});
|
|
|
|
test('should hide refund form when clicking Cancel', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Click Issue Refund
|
|
await page.getByRole('button', { name: /issue refund/i }).click();
|
|
await expect(page.getByText(/full refund/i)).toBeVisible();
|
|
|
|
// Click Cancel
|
|
await page.getByRole('button', { name: /cancel/i }).click();
|
|
|
|
// Refund form should be hidden
|
|
await expect(page.getByText(/full refund/i)).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Export Functionality', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockPaymentConfig),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
const url = route.request().url();
|
|
if (url.includes('/export')) {
|
|
// Return a blob for export
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'text/csv',
|
|
body: 'id,amount,status\n1,5000,succeeded',
|
|
});
|
|
} else if (url.includes('/summary')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSummary),
|
|
});
|
|
} else if (url.includes('/balance')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockBalance),
|
|
});
|
|
} else {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactions),
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
test('should show Export Data button', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Should show Export Data button
|
|
await expect(page.getByRole('button', { name: /export data/i })).toBeVisible();
|
|
});
|
|
|
|
test('should open export modal when clicking Export Data', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click Export Data button
|
|
await page.getByRole('button', { name: /export data/i }).click();
|
|
|
|
// Should show export modal
|
|
await expect(page.getByRole('heading', { name: /export transactions/i })).toBeVisible();
|
|
});
|
|
|
|
test('should show export format options', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click Export Data button
|
|
await page.getByRole('button', { name: /export data/i }).click();
|
|
|
|
// Should show format options
|
|
await expect(page.getByText('CSV')).toBeVisible();
|
|
await expect(page.getByText('Excel')).toBeVisible();
|
|
await expect(page.getByText('PDF')).toBeVisible();
|
|
await expect(page.getByText('QuickBooks')).toBeVisible();
|
|
});
|
|
|
|
test('should have date range inputs in export modal', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click Export Data button
|
|
await page.getByRole('button', { name: /export data/i }).click();
|
|
|
|
// Should show date range inputs
|
|
await expect(page.getByText(/date range/i)).toBeVisible();
|
|
const dateInputs = page.locator('[role="dialog"]').locator('input[type="date"]');
|
|
await expect(dateInputs.first()).toBeVisible();
|
|
});
|
|
|
|
test('should close export modal when clicking X', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click Export Data button
|
|
await page.getByRole('button', { name: /export data/i }).click();
|
|
await expect(page.getByRole('heading', { name: /export transactions/i })).toBeVisible();
|
|
|
|
// Close modal
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForTimeout(300);
|
|
|
|
// Modal should be closed
|
|
await expect(page.getByRole('heading', { name: /export transactions/i })).not.toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Payouts Tab', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockPaymentConfig),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
const url = route.request().url();
|
|
if (url.includes('/payouts')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
payouts: [
|
|
{
|
|
id: 'po_test123',
|
|
amount: 100000,
|
|
amount_display: '$1,000.00',
|
|
currency: 'usd',
|
|
status: 'paid',
|
|
arrival_date: Date.now() / 1000,
|
|
method: 'standard',
|
|
},
|
|
{
|
|
id: 'po_test456',
|
|
amount: 50000,
|
|
amount_display: '$500.00',
|
|
currency: 'usd',
|
|
status: 'pending',
|
|
arrival_date: Date.now() / 1000 + 86400,
|
|
method: 'standard',
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
} else if (url.includes('/balance')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockBalance),
|
|
});
|
|
} else if (url.includes('/summary')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSummary),
|
|
});
|
|
} else {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactions),
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
test('should navigate to payouts tab', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Payouts tab
|
|
await page.getByRole('button', { name: /payouts/i }).click();
|
|
|
|
// Should show balance summary
|
|
await expect(page.getByText(/available for payout/i)).toBeVisible();
|
|
await expect(page.getByText(/pending/i)).toBeVisible();
|
|
});
|
|
|
|
test('should display payout history', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Payouts tab
|
|
await page.getByRole('button', { name: /payouts/i }).click();
|
|
|
|
// Should show payout history section
|
|
await expect(page.getByText(/payout history/i)).toBeVisible();
|
|
});
|
|
|
|
test('should show payout table headers', async ({ page }) => {
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click on Payouts tab
|
|
await page.getByRole('button', { name: /payouts/i }).click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show table headers
|
|
await expect(page.getByText('Payout ID').first()).toBeVisible();
|
|
await expect(page.getByText('Arrival Date').first()).toBeVisible();
|
|
});
|
|
});
|
|
|
|
test.describe('Payment Setup Required State', () => {
|
|
test('should show setup required message when payments not configured', async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
|
|
// Mock unconfigured payment status
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
payment_mode: 'none',
|
|
tier: 'Professional',
|
|
can_accept_payments: false,
|
|
api_keys: null,
|
|
connect_account: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ count: 0, total_pages: 0, results: [] }),
|
|
});
|
|
});
|
|
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Should show setup required message
|
|
await expect(page.getByText(/payment setup required/i)).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /go to settings/i })).toBeVisible();
|
|
});
|
|
|
|
test('should navigate to settings when clicking Go to Settings', async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
payment_mode: 'none',
|
|
tier: 'Professional',
|
|
can_accept_payments: false,
|
|
api_keys: null,
|
|
connect_account: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ count: 0, total_pages: 0, results: [] }),
|
|
});
|
|
});
|
|
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Click Go to Settings
|
|
await page.getByRole('button', { name: /go to settings/i }).click();
|
|
|
|
// Settings tab should be active
|
|
const settingsTab = page.getByRole('button', { name: /settings/i });
|
|
await expect(settingsTab).toHaveClass(/border-brand-500|text-brand/);
|
|
});
|
|
});
|
|
|
|
test.describe('Error Handling', () => {
|
|
test('should handle transaction load error gracefully', async ({ page }) => {
|
|
await page.context().clearCookies();
|
|
|
|
await page.route('**/api/payments/config/status/**', (route) => {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockPaymentConfig),
|
|
});
|
|
});
|
|
|
|
// Mock transaction API error
|
|
await page.route('**/api/payments/transactions/**', (route) => {
|
|
if (route.request().url().includes('/summary')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockSummary),
|
|
});
|
|
} else if (route.request().url().includes('/balance')) {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockBalance),
|
|
});
|
|
} else if (route.request().url().match(/\/transactions\/\d+\/?$/)) {
|
|
route.fulfill({
|
|
status: 500,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'Internal server error' }),
|
|
});
|
|
} else {
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(mockTransactions),
|
|
});
|
|
}
|
|
});
|
|
|
|
await loginAsBusinessOwner(page);
|
|
await navigateToPayments(page);
|
|
|
|
// Navigate to transactions and try to open modal
|
|
await page.getByRole('button', { name: /transactions/i }).click();
|
|
await page.waitForTimeout(500);
|
|
await page.getByRole('button', { name: /view/i }).first().click();
|
|
await page.waitForTimeout(500);
|
|
|
|
// Should show error message in modal
|
|
await expect(page.getByText(/failed to load/i)).toBeVisible();
|
|
});
|
|
});
|