Initial commit: SmoothSchedule multi-tenant scheduling platform
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>
This commit is contained in:
BIN
legacy_reference/frontend/tests/e2e/after-reload.png
Normal file
BIN
legacy_reference/frontend/tests/e2e/after-reload.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
139
legacy_reference/frontend/tests/e2e/auth-persistence.spec.ts
Normal file
139
legacy_reference/frontend/tests/e2e/auth-persistence.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Authentication Persistence Test
|
||||
*
|
||||
* CRITICAL TEST: Ensures authentication persists across page reloads
|
||||
*
|
||||
* This test verifies that:
|
||||
* 1. Cookies are properly set with domain=.lvh.me
|
||||
* 2. Tokens persist in cookies after page reload
|
||||
* 3. The API client correctly sends tokens with requests
|
||||
* 4. Users remain logged in after refreshing the page
|
||||
*
|
||||
* DO NOT REMOVE OR DISABLE THIS TEST
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Authentication Persistence', () => {
|
||||
test('should maintain authentication after page reload', async ({ page }) => {
|
||||
// Navigate to platform subdomain
|
||||
await page.goto('http://platform.lvh.me:5173/');
|
||||
|
||||
// Should see login page
|
||||
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
||||
|
||||
// Fill in credentials
|
||||
await page.fill('input[name="username"]', 'poduck');
|
||||
await page.fill('input[name="password"]', 'starry12');
|
||||
|
||||
// Click sign in
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for navigation/login to complete
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check cookies are set
|
||||
const cookies = await page.context().cookies();
|
||||
console.log('Cookies after login:', cookies);
|
||||
|
||||
const accessToken = cookies.find(c => c.name === 'access_token');
|
||||
const refreshToken = cookies.find(c => c.name === 'refresh_token');
|
||||
|
||||
console.log('Access token:', accessToken);
|
||||
console.log('Refresh token:', refreshToken);
|
||||
|
||||
// Verify tokens exist
|
||||
expect(accessToken).toBeTruthy();
|
||||
expect(refreshToken).toBeTruthy();
|
||||
|
||||
// Should be logged in - check for platform dashboard or user content
|
||||
const currentUrl = page.url();
|
||||
console.log('Current URL after login:', currentUrl);
|
||||
|
||||
// Wait for any dashboard content to appear
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Take screenshot before reload
|
||||
await page.screenshot({ path: 'tests/e2e/before-reload.png' });
|
||||
|
||||
// Get page content before reload
|
||||
const contentBeforeReload = await page.content();
|
||||
console.log('Page title before reload:', await page.title());
|
||||
|
||||
// Reload the page
|
||||
console.log('Reloading page...');
|
||||
|
||||
// Listen to console logs
|
||||
page.on('console', msg => {
|
||||
console.log('BROWSER CONSOLE:', msg.type(), msg.text());
|
||||
});
|
||||
|
||||
// Listen to network requests
|
||||
page.on('request', request => {
|
||||
if (request.url().includes('/api/auth')) {
|
||||
console.log('REQUEST:', request.method(), request.url());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', response => {
|
||||
if (response.url().includes('/api/auth')) {
|
||||
console.log('RESPONSE:', response.status(), response.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check cookies after reload
|
||||
const cookiesAfterReload = await page.context().cookies();
|
||||
console.log('Cookies after reload:', cookiesAfterReload);
|
||||
|
||||
const accessTokenAfterReload = cookiesAfterReload.find(c => c.name === 'access_token');
|
||||
const refreshTokenAfterReload = cookiesAfterReload.find(c => c.name === 'refresh_token');
|
||||
|
||||
console.log('Access token after reload:', accessTokenAfterReload);
|
||||
console.log('Refresh token after reload:', refreshTokenAfterReload);
|
||||
|
||||
// Take screenshot after reload
|
||||
await page.screenshot({ path: 'tests/e2e/after-reload.png' });
|
||||
|
||||
// Get page content after reload
|
||||
console.log('Page title after reload:', await page.title());
|
||||
console.log('Current URL after reload:', page.url());
|
||||
|
||||
// Should still have tokens
|
||||
expect(accessTokenAfterReload, 'Access token should persist after reload').toBeTruthy();
|
||||
expect(refreshTokenAfterReload, 'Refresh token should persist after reload').toBeTruthy();
|
||||
|
||||
// Should NOT see login page
|
||||
const hasLoginForm = await page.locator('input[name="username"]').count();
|
||||
expect(hasLoginForm, 'Should not see login form after reload').toBe(0);
|
||||
});
|
||||
|
||||
test('should inspect cookie storage mechanism', async ({ page }) => {
|
||||
// Navigate to platform subdomain
|
||||
await page.goto('http://platform.lvh.me:5173/');
|
||||
|
||||
// Execute script to test cookie utilities
|
||||
const cookieTest = await page.evaluate(() => {
|
||||
// @ts-ignore
|
||||
const { setCookie, getCookie } = window;
|
||||
|
||||
// Test setting a cookie
|
||||
document.cookie = 'test_cookie=test_value;path=/;domain=.lvh.me;SameSite=Lax';
|
||||
|
||||
// Read back
|
||||
const allCookies = document.cookie;
|
||||
|
||||
return {
|
||||
hostname: window.location.hostname,
|
||||
allCookies,
|
||||
testCookie: document.cookie.split(';').find(c => c.includes('test_cookie'))
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Cookie test results:', cookieTest);
|
||||
});
|
||||
});
|
||||
60
legacy_reference/frontend/tests/e2e/auth.spec.ts
Normal file
60
legacy_reference/frontend/tests/e2e/auth.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Authentication Flow Tests
|
||||
* Tests login, logout, and authentication state management
|
||||
*/
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('should display login page', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Check if login form elements are present
|
||||
await expect(page.getByRole('heading', { name: /login|sign in/i })).toBeVisible();
|
||||
await expect(page.getByLabel(/username/i)).toBeVisible();
|
||||
await expect(page.getByLabel(/password/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /login|sign in/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show error on invalid credentials', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel(/username/i).fill('invaliduser');
|
||||
await page.getByLabel(/password/i).fill('wrongpassword');
|
||||
await page.getByRole('button', { name: /login|sign in/i }).click();
|
||||
|
||||
// Should show error message
|
||||
await expect(page.getByText(/invalid|error|incorrect/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should login successfully with valid credentials', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Fill in credentials (these would be test user credentials)
|
||||
await page.getByLabel(/username/i).fill('testowner');
|
||||
await page.getByLabel(/password/i).fill('testpass123');
|
||||
await page.getByRole('button', { name: /login|sign in/i }).click();
|
||||
|
||||
// Should redirect to dashboard
|
||||
await expect(page).toHaveURL(/dashboard|\/$/);
|
||||
|
||||
// Should show user menu or profile
|
||||
await expect(page.getByRole('button', { name: /profile|account|menu/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should logout successfully', async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto('/');
|
||||
await page.getByLabel(/username/i).fill('testowner');
|
||||
await page.getByLabel(/password/i).fill('testpass123');
|
||||
await page.getByRole('button', { name: /login|sign in/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/dashboard|\/$/);
|
||||
|
||||
// Click logout
|
||||
await page.getByRole('button', { name: /logout|sign out/i }).click();
|
||||
|
||||
// Should redirect to login
|
||||
await expect(page).toHaveURL(/login|\/$/);
|
||||
});
|
||||
});
|
||||
BIN
legacy_reference/frontend/tests/e2e/before-reload.png
Normal file
BIN
legacy_reference/frontend/tests/e2e/before-reload.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,199 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Cookie Cross-Subdomain Tests
|
||||
* Verifies that cookies are properly set with domain=.lvh.me and accessible across subdomains
|
||||
*/
|
||||
|
||||
test.describe('Cookie Cross-Subdomain Access', () => {
|
||||
// Increase timeout for these tests since they involve multiple page navigations
|
||||
test.setTimeout(60000);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear all cookies before each test
|
||||
await page.context().clearCookies();
|
||||
});
|
||||
|
||||
test('should set cookies with domain=.lvh.me after login', async ({ page }) => {
|
||||
// Navigate to platform subdomain
|
||||
await page.goto('http://platform.lvh.me:5173');
|
||||
|
||||
// Wait for login page
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByRole('heading', { name: /sign in to your account/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Login with valid credentials
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Wait for dashboard to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Get all cookies
|
||||
const cookies = await page.context().cookies();
|
||||
|
||||
// Find access_token cookie
|
||||
const accessTokenCookie = cookies.find(c => c.name === 'access_token');
|
||||
expect(accessTokenCookie).toBeDefined();
|
||||
expect(accessTokenCookie?.domain).toBe('.lvh.me');
|
||||
expect(accessTokenCookie?.value).toBeTruthy();
|
||||
expect(accessTokenCookie?.value.length).toBeGreaterThan(20); // JWT tokens are long
|
||||
|
||||
// Find refresh_token cookie
|
||||
const refreshTokenCookie = cookies.find(c => c.name === 'refresh_token');
|
||||
expect(refreshTokenCookie).toBeDefined();
|
||||
expect(refreshTokenCookie?.domain).toBe('.lvh.me');
|
||||
expect(refreshTokenCookie?.value).toBeTruthy();
|
||||
|
||||
console.log('✓ Cookies set with domain=.lvh.me');
|
||||
console.log(' - access_token:', accessTokenCookie?.value.substring(0, 30) + '...');
|
||||
console.log(' - refresh_token:', refreshTokenCookie?.value.substring(0, 30) + '...');
|
||||
});
|
||||
|
||||
test('should access cookies on different subdomain after login', async ({ page }) => {
|
||||
// Step 1: Login on platform.lvh.me
|
||||
await page.goto('http://platform.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Get cookies after login on platform.lvh.me
|
||||
const cookiesAfterLogin = await page.context().cookies();
|
||||
const accessToken = cookiesAfterLogin.find(c => c.name === 'access_token')?.value;
|
||||
|
||||
expect(accessToken).toBeTruthy();
|
||||
console.log('✓ Logged in on platform.lvh.me, got token:', accessToken?.substring(0, 30) + '...');
|
||||
|
||||
// Step 2: Navigate to base domain (lvh.me)
|
||||
await page.goto('http://lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get cookies on base domain
|
||||
const cookiesOnBase = await page.context().cookies('http://lvh.me:5173');
|
||||
const accessTokenOnBase = cookiesOnBase.find(c => c.name === 'access_token')?.value;
|
||||
|
||||
expect(accessTokenOnBase).toBe(accessToken);
|
||||
console.log('✓ Same token accessible on lvh.me:', accessTokenOnBase?.substring(0, 30) + '...');
|
||||
|
||||
// Step 3: Navigate to a different subdomain (any other subdomain)
|
||||
await page.goto('http://test.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get cookies on different subdomain
|
||||
const cookiesOnOtherSubdomain = await page.context().cookies('http://test.lvh.me:5173');
|
||||
const accessTokenOnOther = cookiesOnOtherSubdomain.find(c => c.name === 'access_token')?.value;
|
||||
|
||||
expect(accessTokenOnOther).toBe(accessToken);
|
||||
console.log('✓ Same token accessible on test.lvh.me:', accessTokenOnOther?.substring(0, 30) + '...');
|
||||
|
||||
console.log('✅ Cookies successfully shared across all subdomains!');
|
||||
});
|
||||
|
||||
test('should maintain authentication when navigating between subdomains', async ({ page }) => {
|
||||
// Login on platform.lvh.me
|
||||
await page.goto('http://platform.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify we're authenticated on platform.lvh.me
|
||||
await expect(page.getByRole('heading', { name: /platform dashboard/i })).toBeVisible();
|
||||
console.log('✓ Authenticated on platform.lvh.me');
|
||||
|
||||
// Navigate to base domain
|
||||
await page.goto('http://lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Cookies should be accessible on base domain too
|
||||
const cookies = await page.context().cookies('http://lvh.me:5173');
|
||||
const accessToken = cookies.find(c => c.name === 'access_token');
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(accessToken?.domain).toBe('.lvh.me');
|
||||
console.log('✓ Cookies still accessible on base domain:', accessToken?.value.substring(0, 30) + '...');
|
||||
|
||||
console.log('✅ Authentication cookies maintained across subdomain navigation!');
|
||||
});
|
||||
|
||||
test('should verify cookie attributes for security', async ({ page }) => {
|
||||
// Login
|
||||
await page.goto('http://platform.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const cookies = await page.context().cookies();
|
||||
const accessTokenCookie = cookies.find(c => c.name === 'access_token');
|
||||
|
||||
expect(accessTokenCookie).toBeDefined();
|
||||
|
||||
// Verify security attributes
|
||||
expect(accessTokenCookie?.domain).toBe('.lvh.me');
|
||||
expect(accessTokenCookie?.path).toBe('/');
|
||||
expect(accessTokenCookie?.sameSite).toBe('Lax');
|
||||
expect(accessTokenCookie?.expires).toBeGreaterThan(Date.now() / 1000); // Should have future expiry
|
||||
|
||||
console.log('✓ Cookie attributes:');
|
||||
console.log(' - domain:', accessTokenCookie?.domain);
|
||||
console.log(' - path:', accessTokenCookie?.path);
|
||||
console.log(' - sameSite:', accessTokenCookie?.sameSite);
|
||||
console.log(' - expires:', new Date((accessTokenCookie?.expires || 0) * 1000).toISOString());
|
||||
console.log(' - httpOnly:', accessTokenCookie?.httpOnly);
|
||||
console.log(' - secure:', accessTokenCookie?.secure);
|
||||
});
|
||||
|
||||
test('should send cookies in API requests from any subdomain', async ({ page }) => {
|
||||
// Login on platform.lvh.me
|
||||
await page.goto('http://platform.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Intercept API requests to verify Authorization header
|
||||
const requestsWithAuth: string[] = [];
|
||||
|
||||
page.on('request', request => {
|
||||
const authHeader = request.headers()['authorization'];
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
requestsWithAuth.push(request.url());
|
||||
console.log('✓ Request with auth:', request.url());
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate to different subdomain and trigger API request
|
||||
await page.goto('http://test.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000); // Wait for potential API requests
|
||||
|
||||
// We should have captured at least one authenticated request
|
||||
// (from useCurrentUser hook on the test subdomain)
|
||||
console.log('📊 Total authenticated requests captured:', requestsWithAuth.length);
|
||||
|
||||
// Even if redirected back to platform, we verified cookies work
|
||||
console.log('✅ Cookie-based authentication working across subdomains!');
|
||||
});
|
||||
});
|
||||
137
legacy_reference/frontend/tests/e2e/email-verification.spec.ts
Normal file
137
legacy_reference/frontend/tests/e2e/email-verification.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Email Verification Flow', () => {
|
||||
test('should complete signup and email verification', async ({ page, context }) => {
|
||||
// Generate unique email for this test
|
||||
const timestamp = Date.now();
|
||||
const testEmail = `test${timestamp}@example.com`;
|
||||
const testUsername = `testuser${timestamp}`;
|
||||
const testPassword = 'TestPassword123!';
|
||||
const businessName = `TestBiz${timestamp}`;
|
||||
const subdomain = `testbiz${timestamp}`;
|
||||
|
||||
console.log('\n=== Starting Email Verification Test ===');
|
||||
console.log(`Email: ${testEmail}`);
|
||||
console.log(`Subdomain: ${subdomain}`);
|
||||
|
||||
// Step 1: Navigate to signup page on ROOT domain
|
||||
console.log('\n1. Navigating to signup page...');
|
||||
await page.goto('http://lvh.me:5173/signup');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Step 2: Fill out signup form - Step 1: Account Info
|
||||
console.log('2. Filling out account info...');
|
||||
await page.getByPlaceholder(/email/i).fill(testEmail);
|
||||
await page.getByPlaceholder(/^username/i).fill(testUsername);
|
||||
await page.getByPlaceholder(/^password/i).first().fill(testPassword);
|
||||
await page.getByPlaceholder(/confirm password/i).fill(testPassword);
|
||||
|
||||
// Click Next
|
||||
await page.getByRole('button', { name: /next/i }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 3: Fill business info
|
||||
console.log('3. Filling out business info...');
|
||||
await page.getByPlaceholder(/business name/i).fill(businessName);
|
||||
await page.getByPlaceholder(/subdomain/i).fill(subdomain);
|
||||
|
||||
// Click Next
|
||||
await page.getByRole('button', { name: /next/i }).click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Step 4: Select Free plan
|
||||
console.log('4. Selecting Free plan...');
|
||||
const freeButton = page.locator('button:has-text("Free")').first();
|
||||
await freeButton.click();
|
||||
|
||||
// Click Create Account
|
||||
await page.getByRole('button', { name: /create account/i }).click();
|
||||
|
||||
// Wait for redirect to verification page on business subdomain
|
||||
console.log('5. Waiting for redirect to email verification page...');
|
||||
await page.waitForURL(`http://${subdomain}.lvh.me:5173/email-verification-required`, {
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
console.log('✓ Signup completed, on email verification page');
|
||||
|
||||
// Step 5: Verify we're on the email verification required page
|
||||
await expect(page.getByText(/email verification required/i)).toBeVisible();
|
||||
await expect(page.getByText(testEmail)).toBeVisible();
|
||||
|
||||
console.log('✓ Email verification page showing correctly');
|
||||
|
||||
// Step 6: Click resend verification email
|
||||
console.log('6. Clicking resend verification email...');
|
||||
await page.getByRole('button', { name: /resend verification email/i }).click();
|
||||
|
||||
// Wait for success message
|
||||
await expect(page.getByText(/verification email sent|email sent/i)).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
console.log('✓ Verification email resent successfully');
|
||||
|
||||
// Step 7: Extract token from database
|
||||
console.log('7. Extracting verification token from database...');
|
||||
|
||||
// Use docker exec to get the token
|
||||
const { execSync } = require('child_process');
|
||||
const tokenQuery = `SELECT email_verification_token FROM users_user WHERE email = '${testEmail}'`;
|
||||
const tokenResult = execSync(
|
||||
`docker compose exec -T db psql -U postgres -d smoothschedule -t -c "${tokenQuery}"`,
|
||||
{ cwd: '/home/poduck/Desktop/smoothschedule', encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const token = tokenResult.trim();
|
||||
console.log(`Token extracted: ${token.substring(0, 20)}...`);
|
||||
|
||||
// Step 8: Open verification link in new context (simulating clicking email link)
|
||||
console.log('8. Opening verification link (simulating email click)...');
|
||||
const verificationUrl = `http://${subdomain}.lvh.me:5173/verify-email?token=${token}`;
|
||||
console.log(`URL: ${verificationUrl}`);
|
||||
|
||||
// Open in new page to simulate clicking link from email
|
||||
const verifyPage = await context.newPage();
|
||||
await verifyPage.goto(verificationUrl);
|
||||
await verifyPage.waitForLoadState('networkidle');
|
||||
|
||||
// Step 9: Verify we're on the verify-email page
|
||||
console.log('9. Checking verification page loaded...');
|
||||
const currentUrl = verifyPage.url();
|
||||
console.log(`Current URL: ${currentUrl}`);
|
||||
|
||||
// Check that we're on the verify-email page
|
||||
expect(currentUrl).toContain('/verify-email');
|
||||
expect(currentUrl).toContain(token);
|
||||
|
||||
// Step 10: Click the "Confirm Verification" button
|
||||
console.log('10. Clicking Confirm Verification button...');
|
||||
await expect(verifyPage.getByRole('button', { name: /confirm verification/i })).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
await verifyPage.getByRole('button', { name: /confirm verification/i }).click();
|
||||
|
||||
// Wait for success message
|
||||
await expect(verifyPage.getByText(/email verified|verified/i)).toBeVisible({
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
console.log('✓ Email verified successfully!');
|
||||
|
||||
// Verify in database that email is now verified
|
||||
const verifiedQuery = `SELECT email_verified FROM users_user WHERE email = '${testEmail}'`;
|
||||
const verifiedResult = execSync(
|
||||
`docker compose exec -T db psql -U postgres -d smoothschedule -t -c "${verifiedQuery}"`,
|
||||
{ cwd: '/home/poduck/Desktop/smoothschedule', encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const isVerified = verifiedResult.trim() === 't';
|
||||
expect(isVerified).toBeTruthy();
|
||||
|
||||
console.log('✓ Database confirms email is verified');
|
||||
console.log('\n=== Test Completed Successfully ===\n');
|
||||
});
|
||||
});
|
||||
BIN
legacy_reference/frontend/tests/e2e/integrated-dashboard.png
Normal file
BIN
legacy_reference/frontend/tests/e2e/integrated-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
legacy_reference/frontend/tests/e2e/integrated-login.png
Normal file
BIN
legacy_reference/frontend/tests/e2e/integrated-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Integrated Frontend Visual Check
|
||||
* Tests the integrated frontend with Tailwind CDN styling
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Integrated Frontend Visual Check', () => {
|
||||
test('should display styled login page', async ({ page }) => {
|
||||
// Navigate to platform subdomain on port 5174
|
||||
await page.goto('http://platform.lvh.me:5174/');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should see login heading
|
||||
const heading = page.getByRole('heading', { name: /sign in/i });
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: 'tests/e2e/integrated-login.png', fullPage: true });
|
||||
|
||||
// Check that body has gray background (Tailwind classes working)
|
||||
const body = page.locator('body');
|
||||
const bgColor = await body.evaluate(el => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
console.log('Body background color:', bgColor);
|
||||
|
||||
// Should NOT be transparent white - should have gray background
|
||||
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(bgColor).not.toBe('rgb(255, 255, 255)');
|
||||
});
|
||||
|
||||
test('should display styled dashboard after login', async ({ page }) => {
|
||||
// Navigate and login
|
||||
await page.goto('http://platform.lvh.me:5174/');
|
||||
await page.fill('input[name="username"]', 'poduck');
|
||||
await page.fill('input[name="password"]', 'starry12');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForTimeout(3000);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: 'tests/e2e/integrated-dashboard.png', fullPage: true });
|
||||
|
||||
// Should have sidebar
|
||||
const sidebar = page.locator('aside, nav').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
console.log('Dashboard loaded successfully');
|
||||
});
|
||||
});
|
||||
57
legacy_reference/frontend/tests/e2e/login-debug.spec.ts
Normal file
57
legacy_reference/frontend/tests/e2e/login-debug.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('debug login flow with subdomain redirect', async ({ page }) => {
|
||||
// Navigate to login page first
|
||||
await page.goto('http://lvh.me:5173');
|
||||
|
||||
// Clear storage
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
console.log('Step 1: On login page');
|
||||
await expect(page.getByRole('heading', { name: /sign in to your account/i })).toBeVisible();
|
||||
|
||||
// Fill credentials
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
|
||||
console.log('Step 2: Filled credentials');
|
||||
|
||||
// Click sign in and wait for navigation
|
||||
await Promise.all([
|
||||
page.waitForURL(/platform\.lvh\.me|lvh\.me/, { timeout: 10000 }),
|
||||
page.getByRole('button', { name: /sign in/i }).click(),
|
||||
]);
|
||||
|
||||
const urlAfterLogin = page.url();
|
||||
console.log('Step 3: URL after login click:', urlAfterLogin);
|
||||
|
||||
// Wait a bit for any redirects/reloads
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const finalUrl = page.url();
|
||||
console.log('Step 4: Final URL:', finalUrl);
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: 'login-debug.png', fullPage: true });
|
||||
|
||||
// Check what's on the page
|
||||
const pageContent = await page.content();
|
||||
console.log('Page title:', await page.title());
|
||||
console.log('Has "Platform Dashboard":', pageContent.includes('Platform Dashboard'));
|
||||
console.log('Has "Loading":', pageContent.includes('Loading'));
|
||||
console.log('Has "Sign in":', pageContent.includes('Sign in'));
|
||||
|
||||
// Check localStorage
|
||||
const tokens = await page.evaluate(() => ({
|
||||
access: localStorage.getItem('access_token'),
|
||||
refresh: localStorage.getItem('refresh_token'),
|
||||
}));
|
||||
console.log('Tokens in localStorage:', {
|
||||
access: tokens.access ? tokens.access.substring(0, 20) + '...' : 'null',
|
||||
refresh: tokens.refresh ? tokens.refresh.substring(0, 20) + '...' : 'null',
|
||||
});
|
||||
});
|
||||
80
legacy_reference/frontend/tests/e2e/login-flow.spec.ts
Normal file
80
legacy_reference/frontend/tests/e2e/login-flow.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Login Flow Test
|
||||
* Tests actual login with poduck credentials
|
||||
*/
|
||||
|
||||
test.describe('Login Flow', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to page first
|
||||
await page.goto('http://lvh.me:5173');
|
||||
|
||||
// Then clear storage
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
});
|
||||
|
||||
test('should display login page on initial load', async ({ page }) => {
|
||||
await page.goto('http://lvh.me:5173');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should show login page, not loading screen
|
||||
await expect(page.getByRole('heading', { name: /sign in to your account/i })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByPlaceholder(/username/i)).toBeVisible();
|
||||
await expect(page.getByPlaceholder(/password/i)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should login with poduck credentials and stay on platform.lvh.me', async ({ page }) => {
|
||||
// Start directly on platform subdomain to allow cookies with domain=.localhost
|
||||
await page.goto('http://platform.lvh.me:5173');
|
||||
|
||||
// Wait for login page
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByRole('heading', { name: /sign in to your account/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Fill in credentials
|
||||
await page.getByPlaceholder(/username/i).fill('poduck');
|
||||
await page.getByPlaceholder(/password/i).fill('starry12');
|
||||
|
||||
// Click sign in
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Should stay on platform.lvh.me:5173 (no redirect needed for platform users)
|
||||
// Tokens are in cookies with domain=.localhost, accessible across subdomains
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check that we see the platform dashboard
|
||||
await expect(page.getByRole('heading', { name: /platform dashboard/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify we're still on platform subdomain
|
||||
const url = page.url();
|
||||
console.log('Current URL after login:', url);
|
||||
expect(url).toContain('platform.lvh.me:5173');
|
||||
});
|
||||
|
||||
test('should show error on invalid credentials', async ({ page }) => {
|
||||
await page.goto('http://lvh.me:5173');
|
||||
|
||||
// Wait for login page
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.getByRole('heading', { name: /sign in to your account/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Fill in invalid credentials
|
||||
await page.getByPlaceholder(/username/i).fill('invaliduser');
|
||||
await page.getByPlaceholder(/password/i).fill('wrongpass');
|
||||
|
||||
// Click sign in
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Should show error message
|
||||
await expect(page.getByText(/invalid credentials/i)).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
98
legacy_reference/frontend/tests/e2e/masquerade-debug.spec.ts
Normal file
98
legacy_reference/frontend/tests/e2e/masquerade-debug.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('debug masquerade functionality', async ({ page }) => {
|
||||
// Enable console logging
|
||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
||||
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
|
||||
|
||||
// Listen for network requests
|
||||
page.on('request', request => {
|
||||
if (request.url().includes('masquerade') || request.url().includes('current')) {
|
||||
console.log('REQUEST:', request.method(), request.url());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', async response => {
|
||||
if (response.url().includes('masquerade') || response.url().includes('current')) {
|
||||
console.log('RESPONSE:', response.status(), response.url());
|
||||
try {
|
||||
const body = await response.text();
|
||||
console.log('RESPONSE BODY:', body.substring(0, 500));
|
||||
} catch (e) {
|
||||
console.log('Could not read response body');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate to platform
|
||||
console.log('\n=== Step 1: Navigate to platform login ===');
|
||||
await page.goto('http://platform.lvh.me:5174/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Login as superuser
|
||||
console.log('\n=== Step 2: Login as superuser ===');
|
||||
await page.locator('#username').fill('poduck');
|
||||
await page.locator('#password').fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Wait for platform dashboard
|
||||
await page.waitForURL(/platform\.lvh\.me/, { timeout: 10000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
console.log('Current URL after login:', page.url());
|
||||
|
||||
// Navigate to Businesses tab
|
||||
console.log('\n=== Step 3: Navigate to Businesses ===');
|
||||
await page.getByRole('button', { name: /businesses/i }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Wait for businesses to load
|
||||
await page.waitForSelector('table tbody tr', { timeout: 10000 });
|
||||
console.log('Businesses loaded');
|
||||
|
||||
// Get the first masquerade button
|
||||
console.log('\n=== Step 4: Click masquerade on first business ===');
|
||||
const masqueradeButtons = page.getByRole('button', { name: /masquerade/i });
|
||||
const firstButton = masqueradeButtons.first();
|
||||
|
||||
// Check if button is visible
|
||||
await expect(firstButton).toBeVisible();
|
||||
console.log('Masquerade button is visible');
|
||||
|
||||
// Get the business info before clicking
|
||||
const businessRow = page.locator('table tbody tr').first();
|
||||
const businessName = await businessRow.locator('td').first().textContent();
|
||||
console.log('Masquerading as owner of:', businessName);
|
||||
|
||||
// Click masquerade
|
||||
console.log('Clicking masquerade button...');
|
||||
await firstButton.click();
|
||||
|
||||
// Wait a bit for the mutation to process
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('\n=== Step 5: Check what happened ===');
|
||||
console.log('Current URL:', page.url());
|
||||
|
||||
// Check cookies
|
||||
const cookies = await page.context().cookies();
|
||||
const accessToken = cookies.find(c => c.name === 'access_token');
|
||||
const refreshToken = cookies.find(c => c.name === 'refresh_token');
|
||||
console.log('Access token present:', !!accessToken);
|
||||
console.log('Refresh token present:', !!refreshToken);
|
||||
if (accessToken) {
|
||||
console.log('Access token domain:', accessToken.domain);
|
||||
}
|
||||
|
||||
// Wait for any redirects
|
||||
await page.waitForTimeout(3000);
|
||||
console.log('Final URL:', page.url());
|
||||
|
||||
// Check page content
|
||||
const pageText = await page.textContent('body');
|
||||
console.log('Page contains "loading":', pageText?.toLowerCase().includes('loading'));
|
||||
console.log('Page contains "error":', pageText?.toLowerCase().includes('error'));
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: '/tmp/masquerade-debug.png', fullPage: true });
|
||||
console.log('Screenshot saved to /tmp/masquerade-debug.png');
|
||||
});
|
||||
97
legacy_reference/frontend/tests/e2e/masquerade-repro.spec.ts
Normal file
97
legacy_reference/frontend/tests/e2e/masquerade-repro.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('masquerade cookie debug', async ({ page }) => {
|
||||
page.on('console', msg => console.log(`BROWSER ${msg.type().toUpperCase()}:`, msg.text()));
|
||||
|
||||
// Step 1: Login as superuser
|
||||
console.log('Step 1: Login as superuser');
|
||||
await page.goto('http://platform.lvh.me:5174/login');
|
||||
|
||||
await page.locator('input[type="text"]').fill('poduck');
|
||||
await page.locator('input[type="password"]').fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
// Wait for redirect to dashboard
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL(url => url.hash.includes('/dashboard') || url.pathname.includes('/dashboard'), { timeout: 15000 });
|
||||
console.log('✓ Logged in successfully');
|
||||
|
||||
// Check cookies immediately
|
||||
let cookies = await page.context().cookies();
|
||||
console.log('Cookies after login:', cookies.map(c => `${c.name}=${c.value.substring(0, 10)}... domain=${c.domain}`));
|
||||
|
||||
// Step 2: Reload page to check persistence
|
||||
console.log('Step 2: Reloading page');
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
cookies = await page.context().cookies();
|
||||
console.log('Cookies after reload:', cookies.map(c => `${c.name}=${c.value.substring(0, 10)}... domain=${c.domain}`));
|
||||
|
||||
// Step 2: Navigate to Users tab
|
||||
console.log('Step 2: Navigate to Users tab');
|
||||
|
||||
// Setup wait for response before navigation
|
||||
const usersResponsePromise = page.waitForResponse(resp => resp.url().includes('/api/platform/users/') && resp.status() === 200);
|
||||
|
||||
await page.goto('http://platform.lvh.me:5174/#/platform/users');
|
||||
await usersResponsePromise;
|
||||
|
||||
// Step 3: Click Masquerade
|
||||
console.log('Step 3: Click Masquerade');
|
||||
|
||||
// Find a business owner to masquerade as (look for "owner" role in the table)
|
||||
// We'll look for the first row that contains "owner" text
|
||||
const ownerRow = page.locator('tr:has-text("owner")').first();
|
||||
const masqueradeBtn = ownerRow.getByRole('button', { name: 'Masquerade' });
|
||||
|
||||
// Get the owner's name for verification later
|
||||
const ownerNameCell = await ownerRow.locator('td').nth(1).textContent();
|
||||
const ownerName = ownerNameCell?.trim() || 'Owner';
|
||||
console.log(`Masquerading as: ${ownerName}`);
|
||||
|
||||
await masqueradeBtn.click();
|
||||
|
||||
// Wait for redirect to the target subdomain with tokens in URL
|
||||
// We expect a redirect to the business subdomain with tokens
|
||||
|
||||
await page.waitForURL(url => {
|
||||
return url.hostname.includes('.lvh.me') &&
|
||||
url.hostname !== 'platform.lvh.me' &&
|
||||
url.searchParams.has('access_token') &&
|
||||
url.searchParams.has('refresh_token');
|
||||
}, { timeout: 15000 });
|
||||
|
||||
const currentUrl = page.url();
|
||||
console.log(`✓ Redirected to target subdomain with tokens: ${currentUrl}`);
|
||||
|
||||
// Wait for the app to process tokens and clean URL
|
||||
await page.waitForURL(url => {
|
||||
return url.hostname.includes('.lvh.me') &&
|
||||
url.hostname !== 'platform.lvh.me' &&
|
||||
!url.searchParams.has('access_token');
|
||||
}, { timeout: 15000 });
|
||||
|
||||
console.log('✓ URL cleaned up');
|
||||
|
||||
// Verify cookies are set on the new domain
|
||||
const newCookies = await page.context().cookies();
|
||||
const accessToken = newCookies.find(c => c.name === 'access_token');
|
||||
const refreshToken = newCookies.find(c => c.name === 'refresh_token');
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
console.log('✓ Cookies set on new domain');
|
||||
} else {
|
||||
throw new Error('Cookies not set on new domain');
|
||||
}
|
||||
|
||||
// Verify masquerade worked - we should be logged in as the owner
|
||||
// The banner may not show due to business data 404, but identity should be correct
|
||||
|
||||
// Wait a bit for page to stabilize
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('✓ Masquerade successful - redirected to business subdomain with owner identity');
|
||||
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('masquerade functionality works end-to-end', async ({ page }) => {
|
||||
// Listen for console logs and errors
|
||||
page.on('console', msg => console.log(`BROWSER ${msg.type().toUpperCase()}:`, msg.text()));
|
||||
page.on('pageerror', err => console.log('PAGE ERROR:', err.message));
|
||||
|
||||
// Listen for network requests
|
||||
page.on('response', async response => {
|
||||
if (response.url().includes('masquerade') || response.url().includes('current')) {
|
||||
console.log(`API ${response.status()} ${response.url()}`);
|
||||
try {
|
||||
const body = await response.text();
|
||||
console.log(`RESPONSE BODY: ${body.substring(0, 500)}`);
|
||||
} catch (e) {
|
||||
// Response body already consumed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Step 1: Login as superuser
|
||||
console.log('Step 1: Login as superuser');
|
||||
await page.goto('http://platform.lvh.me:5174/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Fill login form
|
||||
await page.locator('#username').fill('poduck');
|
||||
await page.locator('#password').fill('starry12');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL(/platform\.lvh\.me/, { timeout: 10000 });
|
||||
|
||||
// Verify we're on platform dashboard
|
||||
await expect(page).toHaveURL(/platform\.lvh\.me/);
|
||||
console.log('✓ Logged in successfully');
|
||||
|
||||
// Step 2: Navigate to Businesses tab
|
||||
console.log('Step 2: Navigate to Businesses tab');
|
||||
await page.getByRole('link', { name: /businesses/i }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Wait for table to load
|
||||
await page.waitForSelector('table tbody tr', { timeout: 10000 });
|
||||
console.log('✓ Businesses page loaded');
|
||||
|
||||
// Step 3: Click masquerade on first business
|
||||
console.log('Step 3: Click masquerade button');
|
||||
const firstMasqueradeButton = page.getByRole('button', { name: /masquerade/i }).first();
|
||||
await firstMasqueradeButton.waitFor({ state: 'visible' });
|
||||
|
||||
// Get business name before clicking
|
||||
const firstRow = page.locator('table tbody tr').first();
|
||||
const businessName = await firstRow.locator('td').first().textContent();
|
||||
console.log(`Masquerading as owner of: ${businessName}`);
|
||||
|
||||
//Click the button
|
||||
await firstMasqueradeButton.click();
|
||||
|
||||
// Step 4: Wait for redirect
|
||||
console.log('Step 4: Wait for redirect to business subdomain');
|
||||
await page.waitForURL(/lvh\.me/, { timeout: 10000 });
|
||||
|
||||
const finalURL = page.url();
|
||||
console.log(`Final URL: ${finalURL}`);
|
||||
|
||||
// Verify we're on a business subdomain (not platform)
|
||||
expect(finalURL).not.toContain('platform.lvh.me');
|
||||
expect(finalURL).toContain('.lvh.me');
|
||||
console.log('✓ Redirected to business subdomain');
|
||||
|
||||
// Step 5: Wait for page to load and verify we see business dashboard
|
||||
await page.waitForLoadState('networkidle', { timeout: 15000 });
|
||||
|
||||
// Check for elements that should be on business dashboard
|
||||
await page.waitForTimeout(2000);
|
||||
const pageContent = await page.content();
|
||||
|
||||
// Should not see platform-specific content
|
||||
expect(pageContent.toLowerCase()).not.toContain('platform dashboard');
|
||||
|
||||
console.log('✓ Business dashboard loaded');
|
||||
|
||||
// Step 6: Verify Customers link is visible
|
||||
console.log('Step 6: Verify Customers link is visible');
|
||||
const customersLink = page.getByRole('link', { name: /customers/i });
|
||||
|
||||
// Wait for navigation to be ready
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await expect(customersLink).toBeVisible({ timeout: 5000 });
|
||||
console.log('✓ Customers link is visible on business dashboard');
|
||||
|
||||
// Take final screenshot
|
||||
await page.screenshot({ path: '/tmp/masquerade-success.png', fullPage: true });
|
||||
console.log('Screenshot saved to /tmp/masquerade-success.png');
|
||||
});
|
||||
77
legacy_reference/frontend/tests/e2e/navigation.spec.ts
Normal file
77
legacy_reference/frontend/tests/e2e/navigation.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Navigation Test
|
||||
* Tests that all menu items navigate to the correct pages
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Platform Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login as platform user
|
||||
await page.goto('http://platform.lvh.me:5173/');
|
||||
await page.fill('input[name="username"]', 'poduck');
|
||||
await page.fill('input[name="password"]', 'starry12');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for login to complete
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('should navigate to Platform Dashboard', async ({ page }) => {
|
||||
// Should be on dashboard after login
|
||||
expect(page.url()).toContain('/platform/dashboard');
|
||||
|
||||
// Verify dashboard content loads
|
||||
await page.waitForTimeout(1000);
|
||||
const hasContent = await page.locator('body').count();
|
||||
expect(hasContent).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should navigate to all platform menu items', async ({ page }) => {
|
||||
// Navigate to Businesses
|
||||
await page.click('a[href="/platform/businesses"]');
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/platform/businesses');
|
||||
|
||||
// Navigate to Users
|
||||
await page.click('a[href="/platform/users"]');
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/platform/users');
|
||||
|
||||
// Navigate to Support
|
||||
await page.click('a[href="/platform/support"]');
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/platform/support');
|
||||
|
||||
// Navigate back to Dashboard
|
||||
await page.click('a[href="/platform/dashboard"]');
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/platform/dashboard');
|
||||
});
|
||||
|
||||
test('should reload and stay on current page', async ({ page }) => {
|
||||
// Navigate to businesses
|
||||
await page.click('a[href="/platform/businesses"]');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const urlBeforeReload = page.url();
|
||||
|
||||
// Reload page
|
||||
await page.reload();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should still be on businesses page
|
||||
expect(page.url()).toBe(urlBeforeReload);
|
||||
|
||||
// Should NOT see login form
|
||||
const hasLoginForm = await page.locator('input[name="username"]').count();
|
||||
expect(hasLoginForm).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Business Navigation (with test business)', () => {
|
||||
test.skip('should navigate to business pages', async ({ page }) => {
|
||||
// This test requires a business user to be set up
|
||||
// Skipping for now until test data is available
|
||||
});
|
||||
});
|
||||
248
legacy_reference/frontend/tests/e2e/onboarding-flow.spec.ts
Normal file
248
legacy_reference/frontend/tests/e2e/onboarding-flow.spec.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Onboarding Flow Tests
|
||||
*
|
||||
* Tests the onboarding wizard for paid-tier businesses:
|
||||
* - Wizard appears for paid tier businesses on first login
|
||||
* - Wizard does not appear for free tier businesses
|
||||
* - Wizard does not appear after setup is complete
|
||||
* - Skip functionality works
|
||||
* - Stripe Connect integration
|
||||
*/
|
||||
|
||||
test.describe('Onboarding Wizard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear storage before each test
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Paid Tier Business Owner', () => {
|
||||
test('should show onboarding wizard on first login for Professional tier', async ({ page }) => {
|
||||
// This test requires a test user with:
|
||||
// - role: owner
|
||||
// - business tier: Professional/Business/Enterprise
|
||||
// - initialSetupComplete: false
|
||||
//
|
||||
// For now, we'll test the component behavior with mock data
|
||||
// by checking that the wizard renders correctly
|
||||
|
||||
// Login as a paid tier business owner
|
||||
await page.goto('http://acme.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Fill login if login page is shown
|
||||
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');
|
||||
}
|
||||
|
||||
// If the user is a paid-tier owner without setup complete,
|
||||
// the onboarding wizard should appear
|
||||
// Look for wizard elements
|
||||
const wizardHeading = page.getByRole('heading', { name: /welcome/i });
|
||||
const getStartedButton = page.getByRole('button', { name: /get started/i });
|
||||
const skipButton = page.getByRole('button', { name: /skip/i });
|
||||
|
||||
// Note: This will only pass if the test user is properly configured
|
||||
// If wizard is not visible, check if we're already past onboarding
|
||||
const isWizardVisible = await wizardHeading.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (isWizardVisible) {
|
||||
await expect(wizardHeading).toBeVisible();
|
||||
await expect(getStartedButton).toBeVisible();
|
||||
await expect(skipButton).toBeVisible();
|
||||
} else {
|
||||
// User may already be onboarded - verify dashboard is shown
|
||||
console.log('Onboarding wizard not shown - user may already be set up');
|
||||
}
|
||||
});
|
||||
|
||||
test('should navigate through wizard steps', async ({ page }) => {
|
||||
// Start on business subdomain
|
||||
await page.goto('http://acme.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// If wizard is shown, test navigation
|
||||
const welcomeHeading = page.getByRole('heading', { name: /welcome/i });
|
||||
const isWizardVisible = await welcomeHeading.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (isWizardVisible) {
|
||||
// Click Get Started to go to Stripe step
|
||||
await page.getByRole('button', { name: /get started/i }).click();
|
||||
|
||||
// Should now see Stripe Connect step
|
||||
await expect(page.getByRole('heading', { name: /connect stripe/i })).toBeVisible();
|
||||
|
||||
// Should see the Connect with Stripe button (or Continue if already connected)
|
||||
const connectButton = page.getByRole('button', { name: /connect with stripe/i });
|
||||
const continueButton = page.getByRole('button', { name: /continue/i });
|
||||
|
||||
const hasConnectButton = await connectButton.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
const hasContinueButton = await continueButton.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
|
||||
expect(hasConnectButton || hasContinueButton).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('should allow skipping onboarding', async ({ page }) => {
|
||||
await page.goto('http://acme.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const welcomeHeading = page.getByRole('heading', { name: /welcome/i });
|
||||
const isWizardVisible = await welcomeHeading.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (isWizardVisible) {
|
||||
// Click skip
|
||||
await page.getByRole('button', { name: /skip/i }).click();
|
||||
|
||||
// Wizard should close
|
||||
await expect(welcomeHeading).not.toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Should see the dashboard
|
||||
// (specific dashboard elements depend on your implementation)
|
||||
}
|
||||
});
|
||||
|
||||
test('should not show wizard again after skip in same session', async ({ page }) => {
|
||||
await page.goto('http://acme.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const welcomeHeading = page.getByRole('heading', { name: /welcome/i });
|
||||
const isWizardVisible = await welcomeHeading.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (isWizardVisible) {
|
||||
// Skip the wizard
|
||||
await page.getByRole('button', { name: /skip/i }).click();
|
||||
await expect(welcomeHeading).not.toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Navigate away and back
|
||||
await page.goto('http://acme.lvh.me:5173/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.goto('http://acme.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wizard should not reappear (dismissed for this session)
|
||||
await expect(welcomeHeading).not.toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Free Tier Business', () => {
|
||||
test('should not show onboarding wizard for free tier', async ({ page }) => {
|
||||
// This test requires a free tier test business
|
||||
// Free tier businesses don't need Stripe Connect onboarding
|
||||
|
||||
// For now, we verify by checking that wizard is NOT shown
|
||||
// when accessing a free tier business
|
||||
console.log('Free tier business test - wizard should not appear');
|
||||
|
||||
// The test would login as a free tier business owner
|
||||
// and verify the wizard doesn't appear
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Stripe Connect Return', () => {
|
||||
test('should handle return from Stripe Connect with success params', async ({ page }) => {
|
||||
// Test the URL parameter handling for Stripe Connect returns
|
||||
await page.goto('http://acme.lvh.me:5173/?onboarding=true&connect=complete');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// The app should:
|
||||
// 1. Detect the connect=complete parameter
|
||||
// 2. Refetch payment config
|
||||
// 3. Show appropriate step in wizard
|
||||
|
||||
// If stripe is now connected, should show completion step
|
||||
const allSetHeading = page.getByRole('heading', { name: /all set/i });
|
||||
const isComplete = await allSetHeading.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (isComplete) {
|
||||
// Should see the completion message
|
||||
await expect(page.getByRole('button', { name: /go to dashboard/i })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle return from Stripe Connect with refresh params', async ({ page }) => {
|
||||
// Test the URL parameter handling for Stripe Connect refresh
|
||||
await page.goto('http://acme.lvh.me:5173/?onboarding=true&connect=refresh');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// The app should detect this and show the Stripe step
|
||||
// with option to continue onboarding
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Onboarding Wizard Components', () => {
|
||||
test('should display step indicators correctly', async ({ page }) => {
|
||||
// Navigate to trigger wizard
|
||||
await page.goto('http://acme.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const welcomeHeading = page.getByRole('heading', { name: /welcome/i });
|
||||
const isWizardVisible = await welcomeHeading.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (isWizardVisible) {
|
||||
// Should show 3 step indicators
|
||||
// Step 1 should be active (blue)
|
||||
// Steps 2 and 3 should be inactive (gray)
|
||||
|
||||
// Navigate to step 2
|
||||
await page.getByRole('button', { name: /get started/i }).click();
|
||||
|
||||
// Step 1 should now show checkmark (completed)
|
||||
// Step 2 should be active
|
||||
}
|
||||
});
|
||||
|
||||
test('should show plan-specific messaging', async ({ page }) => {
|
||||
await page.goto('http://acme.lvh.me:5173');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const stripeHeading = page.getByRole('heading', { name: /connect stripe/i });
|
||||
|
||||
// Navigate to Stripe step if wizard is visible
|
||||
const welcomeHeading = page.getByRole('heading', { name: /welcome/i });
|
||||
const isWizardVisible = await welcomeHeading.isVisible({ timeout: 5000 }).catch(() => false);
|
||||
|
||||
if (isWizardVisible) {
|
||||
await page.getByRole('button', { name: /get started/i }).click();
|
||||
|
||||
// Should mention the business plan tier
|
||||
const planText = page.getByText(/professional|business|enterprise/i);
|
||||
await expect(planText.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Payment Configuration Status', () => {
|
||||
test('should show Stripe Connected status when Connect is set up', async ({ page }) => {
|
||||
await page.goto('http://acme.lvh.me:5173');
|
||||
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');
|
||||
}
|
||||
|
||||
// Navigate to settings/payments if needed
|
||||
// Check for payment status indicators
|
||||
});
|
||||
|
||||
test('should show setup required status when Connect is not set up', async ({ page }) => {
|
||||
// Navigate to a business that hasn't set up payments
|
||||
// Verify the "Setup Required" status is shown
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Original Frontend Screenshots
|
||||
* Captures screenshots from the original standalone frontend
|
||||
*/
|
||||
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test.describe('Original Frontend Screenshots', () => {
|
||||
test('should capture original login page', async ({ page }) => {
|
||||
// Navigate to original frontend on port 3000
|
||||
await page.goto('http://localhost:3000/');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: 'tests/e2e/original-login.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('should capture original platform pages', async ({ page }) => {
|
||||
// Navigate to original frontend on port 3000
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Try to find "Platform" link or button to navigate to platform section
|
||||
const platformLink = page.locator('text=Platform').first();
|
||||
if (await platformLink.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await platformLink.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await page.screenshot({ path: 'tests/e2e/original-platform-dashboard.png', fullPage: true });
|
||||
}
|
||||
|
||||
// Take general screenshot
|
||||
await page.screenshot({ path: 'tests/e2e/original-main-page.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Test: Appointment disappears when dragged past 8 PM
|
||||
*
|
||||
* This test reproduces the bug where dragging an appointment
|
||||
* past 8 PM on the scheduler causes it to disappear.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Scheduler Drag Past 8 PM Bug', () => {
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
// Clear cookies to avoid stale auth issues
|
||||
await context.clearCookies();
|
||||
|
||||
// Login as acme business owner
|
||||
await page.goto('http://acme.lvh.me:5173/login');
|
||||
await page.fill('input[name="username"], input[placeholder*="username" i], input[type="text"]', 'acme_owner');
|
||||
await page.fill('input[name="password"], input[placeholder*="password" i], input[type="password"]', 'password123');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for dashboard to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Click on Scheduler in the sidebar
|
||||
await page.getByRole('link', { name: 'Scheduler' }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
test('should not disappear when dragging appointment past 8 PM', async ({ page }) => {
|
||||
// Wait for scheduler to load
|
||||
await page.waitForSelector('.timeline-scroll', { timeout: 10000 });
|
||||
|
||||
// Find an appointment on the scheduler
|
||||
const appointments = page.locator('[draggable="true"]').filter({ hasText: /min/ });
|
||||
const appointmentCount = await appointments.count();
|
||||
|
||||
console.log(`Found ${appointmentCount} appointments`);
|
||||
|
||||
if (appointmentCount === 0) {
|
||||
test.skip('No appointments found to test with');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the first appointment
|
||||
const appointment = appointments.first();
|
||||
const appointmentText = await appointment.textContent();
|
||||
console.log(`Testing with appointment: ${appointmentText}`);
|
||||
|
||||
// Get initial bounding box
|
||||
const initialBox = await appointment.boundingBox();
|
||||
if (!initialBox) {
|
||||
test.skip('Could not get appointment bounding box');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Initial position: x=${initialBox.x}, y=${initialBox.y}`);
|
||||
|
||||
// Find the timeline container
|
||||
const timeline = page.locator('.timeline-scroll');
|
||||
const timelineBox = await timeline.boundingBox();
|
||||
if (!timelineBox) {
|
||||
throw new Error('Could not get timeline bounding box');
|
||||
}
|
||||
|
||||
// Calculate 8 PM position (20 hours * 60 minutes * 2.5 pixels per minute = 3000 pixels from start)
|
||||
// Plus we need to scroll and find the exact position
|
||||
const pixelsPerMinute = 2.5;
|
||||
const eightPMMinutes = 20 * 60; // 1200 minutes
|
||||
const tenPMMinutes = 22 * 60; // 1320 minutes - drag past 8 PM
|
||||
const targetX = tenPMMinutes * pixelsPerMinute; // 3300 pixels from midnight
|
||||
|
||||
console.log(`Target X position for 10 PM: ${targetX}`);
|
||||
|
||||
// First, scroll the timeline to show the 8 PM area
|
||||
await timeline.evaluate((el, scrollTo) => {
|
||||
el.scrollLeft = scrollTo - 500; // Scroll to show 8 PM area with some padding
|
||||
}, targetX);
|
||||
|
||||
await page.waitForTimeout(500); // Wait for scroll to complete
|
||||
|
||||
// Get the scroll position
|
||||
const scrollLeft = await timeline.evaluate(el => el.scrollLeft);
|
||||
console.log(`Scrolled to: ${scrollLeft}`);
|
||||
|
||||
// Now drag the appointment to 10 PM position (past 8 PM)
|
||||
// The target position relative to the viewport
|
||||
const targetXViewport = timelineBox.x + (targetX - scrollLeft);
|
||||
const targetY = initialBox.y + initialBox.height / 2;
|
||||
|
||||
console.log(`Dragging to viewport position: x=${targetXViewport}, y=${targetY}`);
|
||||
|
||||
// Perform the drag
|
||||
await appointment.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetXViewport, targetY, { steps: 20 });
|
||||
await page.waitForTimeout(100);
|
||||
await page.mouse.up();
|
||||
|
||||
// Wait for any updates
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Take a screenshot for debugging
|
||||
await page.screenshot({ path: 'scheduler-after-drag-to-10pm.png', fullPage: true });
|
||||
|
||||
// Check if the appointment still exists
|
||||
// First, look for it in the timeline
|
||||
const appointmentsAfterDrag = page.locator('[draggable="true"]').filter({ hasText: /min/ });
|
||||
const countAfterDrag = await appointmentsAfterDrag.count();
|
||||
|
||||
console.log(`Appointments after drag: ${countAfterDrag}`);
|
||||
|
||||
// The appointment should still be visible (either in timeline or pending)
|
||||
expect(countAfterDrag).toBeGreaterThanOrEqual(appointmentCount);
|
||||
|
||||
// Also check console for any errors
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Check network requests for failed appointment updates
|
||||
const failedRequests: string[] = [];
|
||||
page.on('response', response => {
|
||||
if (response.url().includes('/appointments/') && response.status() >= 400) {
|
||||
failedRequests.push(`${response.url()} - ${response.status()}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
if (consoleErrors.length > 0) {
|
||||
console.log('Console errors:', consoleErrors);
|
||||
}
|
||||
|
||||
if (failedRequests.length > 0) {
|
||||
console.log('Failed requests:', failedRequests);
|
||||
}
|
||||
});
|
||||
|
||||
test('debug: log scheduler time range and appointments', async ({ page }) => {
|
||||
// Wait for scheduler to load
|
||||
await page.waitForSelector('.timeline-scroll', { timeout: 10000 });
|
||||
|
||||
// Take initial screenshot
|
||||
await page.screenshot({ path: 'scheduler-initial.png', fullPage: true });
|
||||
|
||||
// Log timeline info
|
||||
const timeline = page.locator('.timeline-scroll');
|
||||
const timelineInfo = await timeline.evaluate(el => ({
|
||||
scrollWidth: el.scrollWidth,
|
||||
clientWidth: el.clientWidth,
|
||||
scrollLeft: el.scrollLeft,
|
||||
}));
|
||||
|
||||
console.log('Timeline info:', timelineInfo);
|
||||
|
||||
// Log all appointments
|
||||
const appointments = page.locator('[draggable="true"]').filter({ hasText: /min/ });
|
||||
const count = await appointments.count();
|
||||
|
||||
console.log(`Total appointments: ${count}`);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const apt = appointments.nth(i);
|
||||
const text = await apt.textContent();
|
||||
const box = await apt.boundingBox();
|
||||
console.log(`Appointment ${i}: "${text}" at position x=${box?.x}, y=${box?.y}, width=${box?.width}`);
|
||||
}
|
||||
|
||||
// Scroll to different times and check visibility
|
||||
const timesToCheck = [
|
||||
{ hour: 8, name: '8 AM' },
|
||||
{ hour: 12, name: '12 PM' },
|
||||
{ hour: 17, name: '5 PM' },
|
||||
{ hour: 20, name: '8 PM' },
|
||||
{ hour: 22, name: '10 PM' },
|
||||
];
|
||||
|
||||
for (const { hour, name } of timesToCheck) {
|
||||
const scrollPosition = hour * 60 * 2.5 - 200; // pixels from midnight, minus padding
|
||||
await timeline.evaluate((el, pos) => { el.scrollLeft = pos; }, scrollPosition);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const visibleApts = await appointments.count();
|
||||
console.log(`At ${name}: ${visibleApts} appointments visible, scrollLeft=${await timeline.evaluate(el => el.scrollLeft)}`);
|
||||
|
||||
await page.screenshot({ path: `scheduler-at-${hour}h.png` });
|
||||
}
|
||||
});
|
||||
|
||||
test('debug: monitor network during drag operation', async ({ page }) => {
|
||||
// Set up network logging
|
||||
const networkLog: { method: string; url: string; status?: number; body?: any }[] = [];
|
||||
|
||||
page.on('request', request => {
|
||||
if (request.url().includes('/api/')) {
|
||||
networkLog.push({
|
||||
method: request.method(),
|
||||
url: request.url(),
|
||||
body: request.postData() ? JSON.parse(request.postData() || '{}') : null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', async response => {
|
||||
if (response.url().includes('/api/')) {
|
||||
const entry = networkLog.find(e => e.url === response.url() && !e.status);
|
||||
if (entry) {
|
||||
entry.status = response.status();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for scheduler
|
||||
await page.waitForSelector('.timeline-scroll', { timeout: 10000 });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Clear log after initial load
|
||||
networkLog.length = 0;
|
||||
|
||||
// Find first appointment
|
||||
const appointments = page.locator('[draggable="true"]').filter({ hasText: /min/ });
|
||||
const appointment = appointments.first();
|
||||
|
||||
if (await appointment.count() === 0) {
|
||||
test.skip('No appointments found');
|
||||
return;
|
||||
}
|
||||
|
||||
const initialBox = await appointment.boundingBox();
|
||||
if (!initialBox) return;
|
||||
|
||||
// Scroll to 8 PM area
|
||||
const timeline = page.locator('.timeline-scroll');
|
||||
await timeline.evaluate(el => { el.scrollLeft = 2800; }); // Near 8 PM
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const timelineBox = await timeline.boundingBox();
|
||||
if (!timelineBox) return;
|
||||
|
||||
// Drag to 9 PM (21 * 60 * 2.5 = 3150, minus scroll)
|
||||
const scrollLeft = await timeline.evaluate(el => el.scrollLeft);
|
||||
const targetX = timelineBox.x + (3150 - scrollLeft);
|
||||
|
||||
console.log('Starting drag to 9 PM...');
|
||||
await appointment.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetX, initialBox.y + 50, { steps: 30 });
|
||||
await page.waitForTimeout(200);
|
||||
await page.mouse.up();
|
||||
|
||||
// Wait for network requests to complete
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('Network requests during drag:', JSON.stringify(networkLog, null, 2));
|
||||
|
||||
// Check if there were any PATCH requests and their responses
|
||||
const patchRequests = networkLog.filter(r => r.method === 'PATCH');
|
||||
console.log(`PATCH requests: ${patchRequests.length}`);
|
||||
|
||||
for (const req of patchRequests) {
|
||||
console.log(`PATCH ${req.url}: status=${req.status}, body=${JSON.stringify(req.body)}`);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'scheduler-after-drag-debug.png' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('scheduler loads and reloads correctly after refactoring', async ({ page }) => {
|
||||
console.log('\n=== Testing Scheduler Refactoring ===\n');
|
||||
|
||||
// Login as business owner
|
||||
console.log('Step 1: Login');
|
||||
await page.goto('http://acme.lvh.me:5173/#/login');
|
||||
await page.getByLabel(/username/i).fill('acme_owner');
|
||||
await page.getByLabel(/password/i).fill('password123');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('✓ Logged in');
|
||||
|
||||
// Navigate to scheduler
|
||||
console.log('\nStep 2: Navigate to Scheduler');
|
||||
await page.goto('http://acme.lvh.me:5173/#/scheduler');
|
||||
|
||||
// Wait for scheduler content to load (look for the "Resources" label)
|
||||
await page.waitForSelector('text=Resources', { timeout: 5000 });
|
||||
const hasTimelineContent = await page.locator('text=Resources').first().isVisible();
|
||||
console.log('✓ Scheduler content visible:', hasTimelineContent);
|
||||
|
||||
// Take screenshot before reload
|
||||
await page.screenshot({ path: 'scheduler-before-reload.png' });
|
||||
|
||||
// Hard reload
|
||||
console.log('\nStep 3: Hard reload scheduler page');
|
||||
await page.reload({ waitUntil: 'networkidle' });
|
||||
|
||||
// Wait for scheduler to reload (look for the "Resources" label)
|
||||
await page.waitForSelector('text=Resources', { timeout: 5000 });
|
||||
const hasContentAfterReload = await page.locator('text=Resources').first().isVisible();
|
||||
console.log('✓ Scheduler visible after reload:', hasContentAfterReload);
|
||||
|
||||
// Take screenshot after reload
|
||||
await page.screenshot({ path: 'scheduler-after-reload.png' });
|
||||
|
||||
// Verify URL didn't change
|
||||
expect(page.url()).toContain('scheduler');
|
||||
console.log('✓ URL correct:', page.url());
|
||||
|
||||
// Final check
|
||||
expect(hasContentAfterReload).toBe(true);
|
||||
console.log('\n✅ Test passed: Scheduler works after reload!');
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,978 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
80
legacy_reference/frontend/tests/e2e/visual-check.spec.ts
Normal file
80
legacy_reference/frontend/tests/e2e/visual-check.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Visual Check Test
|
||||
* Ensures pages load with proper styling (not blank white pages)
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Visual Check', () => {
|
||||
test('should display login page with styling', async ({ page }) => {
|
||||
// Navigate to platform subdomain
|
||||
await page.goto('http://platform.lvh.me:5173/');
|
||||
|
||||
// Wait for page to load
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Should see login form heading
|
||||
await expect(page.getByRole('heading', { name: /sign in/i })).toBeVisible();
|
||||
|
||||
// Check that page has background color (not blank white)
|
||||
const body = page.locator('body');
|
||||
const bgColor = await body.evaluate(el => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
console.log('Body background color:', bgColor);
|
||||
|
||||
// Should have some background color set (Tailwind should be working)
|
||||
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
|
||||
expect(bgColor).not.toBe('');
|
||||
|
||||
// Check that the form has styling
|
||||
const form = page.locator('form').first();
|
||||
const formDisplay = await form.evaluate(el => {
|
||||
const styles = window.getComputedStyle(el);
|
||||
return {
|
||||
padding: styles.padding,
|
||||
backgroundColor: styles.backgroundColor,
|
||||
borderRadius: styles.borderRadius,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Form styles:', formDisplay);
|
||||
|
||||
// Form should have some padding/styling
|
||||
expect(formDisplay.padding).not.toBe('0px');
|
||||
});
|
||||
|
||||
test('should display platform dashboard with styling after login', async ({ page }) => {
|
||||
// Navigate and login
|
||||
await page.goto('http://platform.lvh.me:5173/');
|
||||
await page.fill('input[name="username"]', 'poduck');
|
||||
await page.fill('input[name="password"]', 'starry12');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for navigation/login to complete
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Should be on dashboard
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Check that we see content (not blank page)
|
||||
const hasContent = await page.locator('body *').count();
|
||||
expect(hasContent).toBeGreaterThan(10); // Should have multiple elements
|
||||
|
||||
// Check for sidebar (part of layout)
|
||||
const sidebar = page.locator('aside, nav').first();
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// Check sidebar has styling
|
||||
const sidebarBg = await sidebar.evaluate(el => {
|
||||
return window.getComputedStyle(el).backgroundColor;
|
||||
});
|
||||
|
||||
console.log('Sidebar background color:', sidebarBg);
|
||||
expect(sidebarBg).not.toBe('rgba(0, 0, 0, 0)');
|
||||
|
||||
// Take screenshot for verification
|
||||
await page.screenshot({ path: 'tests/e2e/dashboard-screenshot.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
92
legacy_reference/frontend/tests/e2e/visual.spec.ts
Normal file
92
legacy_reference/frontend/tests/e2e/visual.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Visual Regression Tests
|
||||
* Compares implemented components with design specifications
|
||||
*/
|
||||
|
||||
test.describe('Visual Appearance', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login before each test
|
||||
await page.goto('/');
|
||||
await page.getByLabel(/username/i).fill('testowner');
|
||||
await page.getByLabel(/password/i).fill('testpass123');
|
||||
await page.getByRole('button', { name: /login|sign in/i }).click();
|
||||
await page.waitForURL(/dashboard|\/$/);
|
||||
});
|
||||
|
||||
test('Dashboard should display key metrics', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Check for metric cards
|
||||
const metricCards = page.locator('[class*="metric"], [class*="card"]');
|
||||
await expect(metricCards.first()).toBeVisible();
|
||||
|
||||
// Take screenshot for visual comparison
|
||||
await expect(page).toHaveScreenshot('dashboard.png', {
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
test('Sidebar navigation should be visible', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Check for sidebar
|
||||
const sidebar = page.locator('[class*="sidebar"], nav');
|
||||
await expect(sidebar.first()).toBeVisible();
|
||||
|
||||
// Check for navigation links
|
||||
await expect(page.getByRole('link', { name: /dashboard/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /scheduler/i })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /customers/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Top bar should display business name and user info', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Check for top bar elements
|
||||
const topBar = page.locator('[class*="topbar"], header').first();
|
||||
await expect(topBar).toBeVisible();
|
||||
|
||||
// Business name or logo should be visible
|
||||
await expect(topBar.locator('img, [class*="logo"], h1')).toBeVisible();
|
||||
|
||||
// User avatar or menu should be visible
|
||||
await expect(topBar.locator('[class*="avatar"], [class*="user-menu"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Customers page should display customer list', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
|
||||
// Check for page heading
|
||||
await expect(page.getByRole('heading', { name: /customers/i })).toBeVisible();
|
||||
|
||||
// Check for customer table or list
|
||||
const customerList = page.locator('table, [class*="customer-list"]');
|
||||
await expect(customerList.first()).toBeVisible();
|
||||
|
||||
// Take screenshot
|
||||
await expect(page).toHaveScreenshot('customers-page.png', {
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
|
||||
test('Scheduler page should display calendar view', async ({ page }) => {
|
||||
await page.goto('/scheduler');
|
||||
|
||||
// Check for page heading
|
||||
await expect(page.getByRole('heading', { name: /scheduler|calendar/i })).toBeVisible();
|
||||
|
||||
// Check for time slots or calendar grid
|
||||
const scheduler = page.locator('[class*="scheduler"], [class*="calendar"]');
|
||||
await expect(scheduler.first()).toBeVisible();
|
||||
|
||||
// Take screenshot
|
||||
await expect(page).toHaveScreenshot('scheduler-page.png', {
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user