Enhance E2E Tests and Configuration (#358)
* Enhance E2E Tests and Configuration - Updated `.gitignore` to exclude `.auth/` directory for cleaner test environment. - Added `test:fast` script in `package.json` for faster Playwright test execution. - Configured Playwright to include a setup project for initializing tests. - Introduced `AUTH_STATES` utility for managing authentication states in tests. - Created `auth.setup.ts` for user authentication tests, ensuring proper login flows. - Refactored various test files to utilize the new authentication state management, improving test reliability and maintainability. - Adjusted team and user billing tests to streamline setup and enhance clarity. - Refactored some dialogs
This commit is contained in:
committed by
GitHub
parent
f157cc7f3e
commit
02e2502dcc
1
apps/e2e/.gitignore
vendored
1
apps/e2e/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules/
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
.auth
|
||||||
|
|||||||
105
apps/e2e/AGENTS.md
Normal file
105
apps/e2e/AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
## End-to-End Testing with Playwright
|
||||||
|
|
||||||
|
### Page Object Pattern (Required)
|
||||||
|
|
||||||
|
Always use Page Objects for test organization and reusability:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example: auth.po.ts
|
||||||
|
export class AuthPageObject {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async signIn(params: { email: string; password: string }) {
|
||||||
|
await this.page.fill('input[name="email"]', params.email);
|
||||||
|
await this.page.fill('input[name="password"]', params.password);
|
||||||
|
await this.page.click('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async signOut() {
|
||||||
|
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||||
|
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reliability Patterns
|
||||||
|
|
||||||
|
**Use `toPass()` for flaky operations** - Always wrap unreliable operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ CORRECT - Reliable email/OTP operations
|
||||||
|
await expect(async () => {
|
||||||
|
const otpCode = await this.getOtpCodeFromEmail(email);
|
||||||
|
expect(otpCode).not.toBeNull();
|
||||||
|
await this.enterOtpCode(otpCode);
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
|
// ✅ CORRECT - Network requests with validation
|
||||||
|
await expect(async () => {
|
||||||
|
const response = await this.page.waitForResponse(resp =>
|
||||||
|
resp.url().includes('auth/v1/user')
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
|
// ✅ CORRECT - Complex operations with custom intervals
|
||||||
|
await expect(async () => {
|
||||||
|
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||||
|
}).toPass({
|
||||||
|
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Data Management
|
||||||
|
|
||||||
|
**Email Testing**: Use `createRandomEmail()` for unique test emails:
|
||||||
|
```typescript
|
||||||
|
createRandomEmail() {
|
||||||
|
const value = Math.random() * 10000000000000;
|
||||||
|
return `${value.toFixed(0)}@makerkit.dev`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**User Bootstrapping**: Use `bootstrapUser()` for consistent test user creation:
|
||||||
|
```typescript
|
||||||
|
await auth.bootstrapUser({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'testingpassword',
|
||||||
|
name: 'Test User'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This method creates a user with an API call.
|
||||||
|
|
||||||
|
To sign in:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await auth.loginAsUser({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'testingpassword',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Selectors
|
||||||
|
|
||||||
|
**Always use `data-test` attributes** for reliable element selection:
|
||||||
|
```typescript
|
||||||
|
// ✅ CORRECT - Use data-test attributes
|
||||||
|
await this.page.click('[data-test="submit-button"]');
|
||||||
|
await this.page.fill('[data-test="email-input"]', email);
|
||||||
|
|
||||||
|
// ✅ OR
|
||||||
|
await this.page.getByTestId('submit-button').click();
|
||||||
|
|
||||||
|
// ❌ AVOID - Fragile selectors
|
||||||
|
await this.page.click('.btn-primary');
|
||||||
|
await this.page.click('button:nth-child(2)');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- **Feature-based folders**: `/tests/authentication/`, `/tests/billing/`
|
||||||
|
- **Page Objects**: `*.po.ts` files for reusable page interactions
|
||||||
|
- **Setup files**: `auth.setup.ts` for global test setup
|
||||||
|
- **Utility classes**: `/tests/utils/` for shared functionality
|
||||||
105
apps/e2e/CLAUDE.md
Normal file
105
apps/e2e/CLAUDE.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
## End-to-End Testing with Playwright
|
||||||
|
|
||||||
|
### Page Object Pattern (Required)
|
||||||
|
|
||||||
|
Always use Page Objects for test organization and reusability:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Example: auth.po.ts
|
||||||
|
export class AuthPageObject {
|
||||||
|
constructor(private readonly page: Page) {}
|
||||||
|
|
||||||
|
async signIn(params: { email: string; password: string }) {
|
||||||
|
await this.page.fill('input[name="email"]', params.email);
|
||||||
|
await this.page.fill('input[name="password"]', params.password);
|
||||||
|
await this.page.click('button[type="submit"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async signOut() {
|
||||||
|
await this.page.click('[data-test="account-dropdown-trigger"]');
|
||||||
|
await this.page.click('[data-test="account-dropdown-sign-out"]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reliability Patterns
|
||||||
|
|
||||||
|
**Use `toPass()` for flaky operations** - Always wrap unreliable operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ CORRECT - Reliable email/OTP operations
|
||||||
|
await expect(async () => {
|
||||||
|
const otpCode = await this.getOtpCodeFromEmail(email);
|
||||||
|
expect(otpCode).not.toBeNull();
|
||||||
|
await this.enterOtpCode(otpCode);
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
|
// ✅ CORRECT - Network requests with validation
|
||||||
|
await expect(async () => {
|
||||||
|
const response = await this.page.waitForResponse(resp =>
|
||||||
|
resp.url().includes('auth/v1/user')
|
||||||
|
);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
|
// ✅ CORRECT - Complex operations with custom intervals
|
||||||
|
await expect(async () => {
|
||||||
|
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||||
|
}).toPass({
|
||||||
|
intervals: [500, 2500, 5000, 7500, 10_000, 15_000, 20_000]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Data Management
|
||||||
|
|
||||||
|
**Email Testing**: Use `createRandomEmail()` for unique test emails:
|
||||||
|
```typescript
|
||||||
|
createRandomEmail() {
|
||||||
|
const value = Math.random() * 10000000000000;
|
||||||
|
return `${value.toFixed(0)}@makerkit.dev`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**User Bootstrapping**: Use `bootstrapUser()` for consistent test user creation:
|
||||||
|
```typescript
|
||||||
|
await auth.bootstrapUser({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'testingpassword',
|
||||||
|
name: 'Test User'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This method creates a user with an API call.
|
||||||
|
|
||||||
|
To sign in:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
await auth.loginAsUser({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'testingpassword',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Selectors
|
||||||
|
|
||||||
|
**Always use `data-test` attributes** for reliable element selection:
|
||||||
|
```typescript
|
||||||
|
// ✅ CORRECT - Use data-test attributes
|
||||||
|
await this.page.click('[data-test="submit-button"]');
|
||||||
|
await this.page.fill('[data-test="email-input"]', email);
|
||||||
|
|
||||||
|
// ✅ OR
|
||||||
|
await this.page.getByTestId('submit-button').click();
|
||||||
|
|
||||||
|
// ❌ AVOID - Fragile selectors
|
||||||
|
await this.page.click('.btn-primary');
|
||||||
|
await this.page.click('button:nth-child(2)');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- **Feature-based folders**: `/tests/authentication/`, `/tests/billing/`
|
||||||
|
- **Page Objects**: `*.po.ts` files for reusable page interactions
|
||||||
|
- **Setup files**: `auth.setup.ts` for global test setup
|
||||||
|
- **Utility classes**: `/tests/utils/` for shared functionality
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "web-e2e",
|
"name": "web-e2e",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"report": "playwright show-report",
|
"report": "playwright show-report",
|
||||||
"test": "playwright test --max-failures=1",
|
"test": "playwright test --max-failures=1",
|
||||||
|
"test:fast": "playwright test --max-failures=1 --workers=16",
|
||||||
|
"test:setup": "playwright test tests/auth.setup.ts",
|
||||||
"test:ui": "playwright test --ui"
|
"test:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"author": "Makerkit",
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.55.0",
|
"@playwright/test": "^1.55.0",
|
||||||
|
"@supabase/supabase-js": "2.57.4",
|
||||||
"@types/node": "^24.5.2",
|
"@types/node": "^24.5.2",
|
||||||
"dotenv": "17.2.2",
|
"dotenv": "17.2.2",
|
||||||
"node-html-parser": "^7.0.1",
|
"node-html-parser": "^7.0.1",
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ dotenvConfig({ path: '.env.local' });
|
|||||||
/**
|
/**
|
||||||
* Number of workers to use in CI. Tweak based on your CI provider's resources.
|
* Number of workers to use in CI. Tweak based on your CI provider's resources.
|
||||||
*/
|
*/
|
||||||
const CI_WORKERS = 3;
|
const CI_WORKERS = 2;
|
||||||
|
|
||||||
const enableBillingTests = process.env.ENABLE_BILLING_TESTS === 'true';
|
const enableBillingTests = process.env.ENABLE_BILLING_TESTS === 'true';
|
||||||
|
|
||||||
const enableTeamAccountTests =
|
const enableTeamAccountTests =
|
||||||
(process.env.ENABLE_TEAM_ACCOUNT_TESTS ?? 'true') === 'true';
|
(process.env.ENABLE_TEAM_ACCOUNT_TESTS ?? 'true') === 'true';
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: 3,
|
retries: 2,
|
||||||
/* Limit parallel tests on CI. */
|
/* Limit parallel tests on CI. */
|
||||||
workers: process.env.CI ? CI_WORKERS : undefined,
|
workers: process.env.CI ? CI_WORKERS : undefined,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
@@ -68,6 +69,7 @@ export default defineConfig({
|
|||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
navigationTimeout: 15 * 1000,
|
navigationTimeout: 15 * 1000,
|
||||||
|
testIdAttribute: 'data-test',
|
||||||
},
|
},
|
||||||
// test timeout set to 2 minutes
|
// test timeout set to 2 minutes
|
||||||
timeout: 120 * 1000,
|
timeout: 120 * 1000,
|
||||||
@@ -77,9 +79,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
|
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
dependencies: ['setup'],
|
||||||
},
|
},
|
||||||
/* Test against mobile viewports. */
|
/* Test against mobile viewports. */
|
||||||
// {
|
// {
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ export class AccountPageObject {
|
|||||||
this.otp = new OtpPo(page);
|
this.otp = new OtpPo(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setup() {
|
|
||||||
return this.auth.signUpFlow('/home/settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateName(name: string) {
|
async updateName(name: string) {
|
||||||
await this.page.fill('[data-test="update-account-name-form"] input', name);
|
await this.page.fill('[data-test="update-account-name-form"] input', name);
|
||||||
await this.page.click('[data-test="update-account-name-form"] button');
|
await this.page.click('[data-test="update-account-name-form"] button');
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
import { Page, expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { AuthPageObject } from '../authentication/auth.po';
|
||||||
import { AccountPageObject } from './account.po';
|
import { AccountPageObject } from './account.po';
|
||||||
import {AuthPageObject} from "../authentication/auth.po";
|
|
||||||
|
|
||||||
test.describe('Account Settings', () => {
|
test.describe('Account Settings', () => {
|
||||||
let page: Page;
|
|
||||||
let account: AccountPageObject;
|
let account: AccountPageObject;
|
||||||
|
let email: string;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
|
email = auth.createRandomEmail();
|
||||||
|
|
||||||
|
auth.bootstrapUser({
|
||||||
|
email,
|
||||||
|
password: 'testingpassword',
|
||||||
|
name: 'Test User',
|
||||||
|
});
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
account = new AccountPageObject(page);
|
account = new AccountPageObject(page);
|
||||||
|
|
||||||
await account.setup();
|
await auth.loginAsUser({
|
||||||
|
email,
|
||||||
|
password: 'testingpassword',
|
||||||
|
next: '/home/settings',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('user can update their profile name', async () => {
|
test('user can update their profile name', async ({ page }) => {
|
||||||
const name = 'John Doe';
|
const name = 'John Doe';
|
||||||
|
|
||||||
const request = account.updateName(name);
|
const request = account.updateName(name);
|
||||||
@@ -28,13 +41,13 @@ test.describe('Account Settings', () => {
|
|||||||
await expect(account.getProfileName()).toHaveText(name);
|
await expect(account.getProfileName()).toHaveText(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('user can update their email', async () => {
|
test('user can update their email', async ({ page }) => {
|
||||||
const email = account.auth.createRandomEmail();
|
const email = account.auth.createRandomEmail();
|
||||||
|
|
||||||
await account.updateEmail(email);
|
await account.updateEmail(email);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('user can update their password', async () => {
|
test('user can update their password', async ({ page }) => {
|
||||||
const password = (Math.random() * 100000).toString();
|
const password = (Math.random() * 100000).toString();
|
||||||
|
|
||||||
const request = account.updatePassword(password);
|
const request = account.updatePassword(password);
|
||||||
@@ -53,10 +66,19 @@ test.describe('Account Settings', () => {
|
|||||||
|
|
||||||
test.describe('Account Deletion', () => {
|
test.describe('Account Deletion', () => {
|
||||||
test('user can delete their own account', async ({ page }) => {
|
test('user can delete their own account', async ({ page }) => {
|
||||||
const account = new AccountPageObject(page);
|
// Create a fresh user for this test since we'll be deleting it
|
||||||
const auth = new AuthPageObject(page);
|
const auth = new AuthPageObject(page);
|
||||||
|
const account = new AccountPageObject(page);
|
||||||
|
|
||||||
const { email } = await account.setup();
|
const email = auth.createRandomEmail();
|
||||||
|
|
||||||
|
await auth.bootstrapUser({
|
||||||
|
email,
|
||||||
|
password: 'testingpassword',
|
||||||
|
name: 'Test User',
|
||||||
|
});
|
||||||
|
|
||||||
|
await auth.loginAsUser({ email, next: '/home/settings' });
|
||||||
|
|
||||||
await account.deleteAccount(email);
|
await account.deleteAccount(email);
|
||||||
|
|
||||||
@@ -70,6 +92,8 @@ test.describe('Account Deletion', () => {
|
|||||||
password: 'testingpassword',
|
password: 'testingpassword',
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(page.locator('[data-test="auth-error-message"]')).toBeVisible();
|
await expect(
|
||||||
|
page.locator('[data-test="auth-error-message"]'),
|
||||||
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,40 +1,23 @@
|
|||||||
import { Page, expect, selectors, test } from '@playwright/test';
|
import { Page, expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { AuthPageObject } from '../authentication/auth.po';
|
import { AuthPageObject } from '../authentication/auth.po';
|
||||||
import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po';
|
import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po';
|
||||||
|
import { AUTH_STATES } from '../utils/auth-state';
|
||||||
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
|
|
||||||
|
|
||||||
test.describe('Admin Auth flow without MFA', () => {
|
test.describe('Admin Auth flow without MFA', () => {
|
||||||
|
AuthPageObject.setupSession(AUTH_STATES.OWNER_USER);
|
||||||
|
|
||||||
test('will return a 404 for non-admin users', async ({ page }) => {
|
test('will return a 404 for non-admin users', async ({ page }) => {
|
||||||
const auth = new AuthPageObject(page);
|
|
||||||
|
|
||||||
await page.goto('/auth/sign-in');
|
|
||||||
|
|
||||||
await auth.signIn({
|
|
||||||
email: 'owner@makerkit.dev',
|
|
||||||
password: 'testingpassword',
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForURL('/home');
|
|
||||||
|
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
|
|
||||||
expect(page.url()).toContain('/404');
|
expect(page.url()).toContain('/404');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Admin Auth flow with Super Admin but without MFA', () => {
|
||||||
|
AuthPageObject.setupSession(AUTH_STATES.TEST_USER);
|
||||||
|
|
||||||
test('will redirect to 404 for admin users without MFA', async ({ page }) => {
|
test('will redirect to 404 for admin users without MFA', async ({ page }) => {
|
||||||
const auth = new AuthPageObject(page);
|
|
||||||
|
|
||||||
await page.goto('/auth/sign-in');
|
|
||||||
|
|
||||||
await auth.signIn({
|
|
||||||
email: 'test@makerkit.dev',
|
|
||||||
password: 'testingpassword',
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForURL('/home');
|
|
||||||
|
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
|
|
||||||
expect(page.url()).toContain('/404');
|
expect(page.url()).toContain('/404');
|
||||||
@@ -42,12 +25,13 @@ test.describe('Admin Auth flow without MFA', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Admin', () => {
|
test.describe('Admin', () => {
|
||||||
// must be serial because OTP verification is not working in parallel
|
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
test.describe('Admin Dashboard', () => {
|
test.describe('Admin Dashboard', () => {
|
||||||
|
AuthPageObject.setupSession(AUTH_STATES.SUPER_ADMIN);
|
||||||
|
|
||||||
test('displays all stat cards', async ({ page }) => {
|
test('displays all stat cards', async ({ page }) => {
|
||||||
await goToAdmin(page);
|
await page.goto('/admin');
|
||||||
|
|
||||||
// Check all stat cards are present
|
// Check all stat cards are present
|
||||||
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
|
||||||
@@ -73,19 +57,14 @@ test.describe('Admin', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Personal Account Management', () => {
|
test.describe('Personal Account Management', () => {
|
||||||
|
AuthPageObject.setupSession(AUTH_STATES.SUPER_ADMIN);
|
||||||
|
|
||||||
let testUserEmail: string;
|
let testUserEmail: string;
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
selectors.setTestIdAttribute('data-test');
|
|
||||||
|
|
||||||
// Create a new test user before each test
|
// Create a new test user before each test
|
||||||
testUserEmail = await createUser(page);
|
testUserEmail = await createUser(page);
|
||||||
|
|
||||||
await goToAdmin(page);
|
|
||||||
|
|
||||||
// Navigate to the newly created user's account page
|
|
||||||
// Note: We need to get the user's ID from the email - this might need adjustment
|
|
||||||
// based on your URL structure
|
|
||||||
await page.goto(`/admin/accounts`);
|
await page.goto(`/admin/accounts`);
|
||||||
|
|
||||||
// use the email as the filter text
|
// use the email as the filter text
|
||||||
@@ -99,6 +78,7 @@ test.describe('Admin', () => {
|
|||||||
await expect(page.getByText('Personal Account')).toBeVisible();
|
await expect(page.getByText('Personal Account')).toBeVisible();
|
||||||
await expect(page.getByTestId('admin-ban-account-button')).toBeVisible();
|
await expect(page.getByTestId('admin-ban-account-button')).toBeVisible();
|
||||||
await expect(page.getByTestId('admin-impersonate-button')).toBeVisible();
|
await expect(page.getByTestId('admin-impersonate-button')).toBeVisible();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByTestId('admin-delete-account-button'),
|
page.getByTestId('admin-delete-account-button'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -106,6 +86,7 @@ test.describe('Admin', () => {
|
|||||||
|
|
||||||
test('ban user flow', async ({ page }) => {
|
test('ban user flow', async ({ page }) => {
|
||||||
await page.getByTestId('admin-ban-account-button').click();
|
await page.getByTestId('admin-ban-account-button').click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Ban User' }),
|
page.getByRole('heading', { name: 'Ban User' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
@@ -189,29 +170,14 @@ test.describe('Admin', () => {
|
|||||||
|
|
||||||
const auth = new AuthPageObject(page);
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
await auth.signIn({
|
await auth.loginAsUser({
|
||||||
email: testUserEmail,
|
email: testUserEmail,
|
||||||
password: 'testingpassword',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('/home');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('impersonate user flow', async ({ page }) => {
|
|
||||||
await page.getByTestId('admin-impersonate-button').click();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: 'Impersonate User' }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
|
||||||
await page.getByRole('button', { name: 'Impersonate User' }).click();
|
|
||||||
|
|
||||||
// Should redirect to home and be logged in as the user
|
|
||||||
await page.waitForURL('/home');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('delete user flow', async ({ page }) => {
|
test('delete user flow', async ({ page }) => {
|
||||||
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
await page.getByTestId('admin-delete-account-button').click();
|
await page.getByTestId('admin-delete-account-button').click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -236,13 +202,10 @@ test.describe('Admin', () => {
|
|||||||
await page.waitForURL('/admin/accounts');
|
await page.waitForURL('/admin/accounts');
|
||||||
|
|
||||||
// Log out
|
// Log out
|
||||||
await page.context().clearCookies();
|
await auth.signOut();
|
||||||
await page.waitForURL('/');
|
await page.waitForURL('/');
|
||||||
|
|
||||||
// Verify user can't log in
|
await auth.goToSignIn();
|
||||||
await page.goto('/auth/sign-in');
|
|
||||||
|
|
||||||
const auth = new AuthPageObject(page);
|
|
||||||
|
|
||||||
await auth.signIn({
|
await auth.signIn({
|
||||||
email: testUserEmail,
|
email: testUserEmail,
|
||||||
@@ -256,130 +219,116 @@ test.describe('Admin', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Team Account Management', () => {
|
test.describe('Impersonation', () => {
|
||||||
test.skip(
|
test('can sign in as a user', async ({ page }) => {
|
||||||
process.env.ENABLE_TEAM_ACCOUNT_TESTS !== 'true',
|
const auth = new AuthPageObject(page);
|
||||||
'Team account tests are disabled',
|
|
||||||
);
|
|
||||||
|
|
||||||
let testUserEmail: string;
|
await auth.loginAsSuperAdmin({});
|
||||||
let teamName: string;
|
const filterText = await createUser(page);
|
||||||
let slug: string;
|
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
selectors.setTestIdAttribute('data-test');
|
|
||||||
|
|
||||||
// Create a new test user and team account
|
|
||||||
testUserEmail = await createUser(page, {
|
|
||||||
afterSignIn: async () => {
|
|
||||||
teamName = `test-${Math.random().toString(36).substring(2, 15)}`;
|
|
||||||
|
|
||||||
const teamAccountPo = new TeamAccountsPageObject(page);
|
|
||||||
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
|
|
||||||
|
|
||||||
slug = teamSlug;
|
|
||||||
|
|
||||||
await teamAccountPo.createTeam({
|
|
||||||
teamName,
|
|
||||||
slug,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await goToAdmin(page);
|
|
||||||
|
|
||||||
await page.goto(`/admin/accounts`);
|
await page.goto(`/admin/accounts`);
|
||||||
|
|
||||||
await filterAccounts(page, teamName);
|
await filterAccounts(page, filterText);
|
||||||
await selectAccount(page, teamName);
|
await selectAccount(page, filterText);
|
||||||
});
|
|
||||||
|
|
||||||
test('displays team account details', async ({ page }) => {
|
await page.getByTestId('admin-impersonate-button').click();
|
||||||
await expect(page.getByText('Team Account')).toBeVisible();
|
|
||||||
await expect(
|
|
||||||
page.getByTestId('admin-delete-account-button'),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delete team account flow', async ({ page }) => {
|
|
||||||
await page.getByTestId('admin-delete-account-button').click();
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('heading', { name: 'Delete Account' }),
|
page.getByRole('heading', { name: 'Impersonate User' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
||||||
// Try with invalid confirmation
|
|
||||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'WRONG');
|
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
|
||||||
await expect(
|
|
||||||
page.getByRole('heading', { name: 'Delete Account' }),
|
|
||||||
).toBeVisible(); // Dialog should still be open
|
|
||||||
|
|
||||||
// Confirm with correct text
|
|
||||||
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('button', { name: 'Impersonate User' }).click();
|
||||||
|
|
||||||
// Should redirect to admin dashboard after deletion
|
// Should redirect to home and be logged in as the user
|
||||||
await expect(page).toHaveURL('/admin/accounts');
|
await page.waitForURL('/home');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function goToAdmin(page: Page) {
|
test.describe('Team Account Management', () => {
|
||||||
const auth = new AuthPageObject(page);
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
await page.goto('/auth/sign-in');
|
test.skip(
|
||||||
|
process.env.ENABLE_TEAM_ACCOUNT_TESTS !== 'true',
|
||||||
|
'Team account tests are disabled',
|
||||||
|
);
|
||||||
|
|
||||||
await auth.signIn({
|
let testUserEmail: string;
|
||||||
email: 'super-admin@makerkit.dev',
|
let teamName: string;
|
||||||
password: 'testingpassword',
|
let slug: string;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
|
// Create a new test user and team account
|
||||||
|
testUserEmail = await createUser(page);
|
||||||
|
|
||||||
|
teamName = `test-${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
|
||||||
|
await auth.loginAsUser({ email: testUserEmail });
|
||||||
|
|
||||||
|
const teamAccountPo = new TeamAccountsPageObject(page);
|
||||||
|
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
|
||||||
|
|
||||||
|
slug = teamSlug;
|
||||||
|
|
||||||
|
await teamAccountPo.createTeam({
|
||||||
|
teamName,
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
|
||||||
|
await auth.signOut();
|
||||||
|
await page.waitForURL('/');
|
||||||
|
|
||||||
|
await auth.loginAsSuperAdmin({});
|
||||||
|
|
||||||
|
await page.goto(`/admin/accounts`);
|
||||||
|
|
||||||
|
await filterAccounts(page, teamName);
|
||||||
|
await selectAccount(page, teamName);
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('/auth/verify');
|
test('delete team account flow', async ({ page }) => {
|
||||||
await page.waitForTimeout(250);
|
await expect(page.getByText('Team Account')).toBeVisible();
|
||||||
|
|
||||||
await expect(async () => {
|
await page.getByTestId('admin-delete-account-button').click();
|
||||||
await auth.submitMFAVerification(MFA_KEY);
|
|
||||||
await page.waitForURL('/home');
|
await expect(
|
||||||
}).toPass({
|
page.getByRole('heading', { name: 'Delete Account' }),
|
||||||
intervals: [
|
).toBeVisible();
|
||||||
500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000,
|
|
||||||
40_000, 45_000, 50_000,
|
// Try with invalid confirmation
|
||||||
],
|
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'WRONG');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Delete Account' }),
|
||||||
|
).toBeVisible(); // Dialog should still be open
|
||||||
|
|
||||||
|
// Confirm with correct text
|
||||||
|
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
// Should redirect to admin dashboard after deletion
|
||||||
|
await expect(page).toHaveURL('/admin/accounts');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.goto('/admin');
|
async function createUser(page: Page) {
|
||||||
}
|
|
||||||
|
|
||||||
async function createUser(
|
|
||||||
page: Page,
|
|
||||||
params: {
|
|
||||||
afterSignIn?: () => Promise<void>;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const auth = new AuthPageObject(page);
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
const password = 'testingpassword';
|
const password = 'testingpassword';
|
||||||
const email = auth.createRandomEmail();
|
const email = auth.createRandomEmail();
|
||||||
|
|
||||||
// sign up
|
// create user using bootstrap method
|
||||||
await page.goto('/auth/sign-up');
|
await auth.bootstrapUser({
|
||||||
|
|
||||||
await auth.signUp({
|
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
repeatPassword: password,
|
name: 'Test User',
|
||||||
});
|
});
|
||||||
|
|
||||||
// confirm email
|
|
||||||
await auth.visitConfirmEmailLink(email);
|
|
||||||
|
|
||||||
if (params.afterSignIn) {
|
|
||||||
await params.afterSignIn();
|
|
||||||
}
|
|
||||||
|
|
||||||
// sign out
|
|
||||||
await auth.signOut();
|
|
||||||
await page.waitForURL('/');
|
|
||||||
|
|
||||||
// return the email
|
// return the email
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
@@ -404,6 +353,6 @@ async function selectAccount(page: Page, email: string) {
|
|||||||
|
|
||||||
await link.click();
|
await link.click();
|
||||||
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForURL(/\/admin\/accounts\/[^\/]+/);
|
||||||
}).toPass();
|
}).toPass();
|
||||||
}
|
}
|
||||||
|
|||||||
37
apps/e2e/tests/auth.setup.ts
Normal file
37
apps/e2e/tests/auth.setup.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { test } from '@playwright/test';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { cwd } from 'node:process';
|
||||||
|
|
||||||
|
import { AuthPageObject } from './authentication/auth.po';
|
||||||
|
|
||||||
|
const testAuthFile = join(cwd(), '.auth/test@makerkit.dev.json');
|
||||||
|
const ownerAuthFile = join(cwd(), '.auth/owner@makerkit.dev.json');
|
||||||
|
const superAdminAuthFile = join(cwd(), '.auth/super-admin@makerkit.dev.json');
|
||||||
|
|
||||||
|
test('authenticate as test user', async ({ page }) => {
|
||||||
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
|
await auth.loginAsUser({
|
||||||
|
email: 'test@makerkit.dev',
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.context().storageState({ path: testAuthFile });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticate as owner user', async ({ page }) => {
|
||||||
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
|
await auth.loginAsUser({
|
||||||
|
email: 'owner@makerkit.dev',
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.context().storageState({ path: ownerAuthFile });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticate as super-admin user', async ({ page }) => {
|
||||||
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
|
await auth.loginAsSuperAdmin({});
|
||||||
|
|
||||||
|
await page.context().storageState({ path: superAdminAuthFile });
|
||||||
|
});
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
import { Page, expect } from '@playwright/test';
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import test, { Page, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
import { AUTH_STATES } from '../utils/auth-state';
|
||||||
import { Mailbox } from '../utils/mailbox';
|
import { Mailbox } from '../utils/mailbox';
|
||||||
|
|
||||||
|
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
|
||||||
|
|
||||||
export class AuthPageObject {
|
export class AuthPageObject {
|
||||||
private readonly page: Page;
|
private readonly page: Page;
|
||||||
private readonly mailbox: Mailbox;
|
private readonly mailbox: Mailbox;
|
||||||
|
|
||||||
|
static MFA_KEY = MFA_KEY;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this.mailbox = new Mailbox(page);
|
this.mailbox = new Mailbox(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
goToSignIn() {
|
static setupSession(user: (typeof AUTH_STATES)[keyof typeof AUTH_STATES]) {
|
||||||
return this.page.goto('/auth/sign-in');
|
test.use({ storageState: user });
|
||||||
}
|
}
|
||||||
|
|
||||||
goToSignUp() {
|
goToSignIn(next?: string) {
|
||||||
return this.page.goto('/auth/sign-up');
|
return this.page.goto(`/auth/sign-in${next ? `?next=${next}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
goToSignUp(next?: string) {
|
||||||
|
return this.page.goto(`/auth/sign-up${next ? `?next=${next}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async signOut() {
|
async signOut() {
|
||||||
@@ -25,7 +36,7 @@ export class AuthPageObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async signIn(params: { email: string; password: string }) {
|
async signIn(params: { email: string; password: string }) {
|
||||||
await this.page.waitForTimeout(500);
|
await this.page.waitForTimeout(100);
|
||||||
|
|
||||||
await this.page.fill('input[name="email"]', params.email);
|
await this.page.fill('input[name="email"]', params.email);
|
||||||
await this.page.fill('input[name="password"]', params.password);
|
await this.page.fill('input[name="password"]', params.password);
|
||||||
@@ -37,7 +48,7 @@ export class AuthPageObject {
|
|||||||
password: string;
|
password: string;
|
||||||
repeatPassword: string;
|
repeatPassword: string;
|
||||||
}) {
|
}) {
|
||||||
await this.page.waitForTimeout(500);
|
await this.page.waitForTimeout(100);
|
||||||
|
|
||||||
await this.page.fill('input[name="email"]', params.email);
|
await this.page.fill('input[name="email"]', params.email);
|
||||||
await this.page.fill('input[name="password"]', params.password);
|
await this.page.fill('input[name="password"]', params.password);
|
||||||
@@ -108,4 +119,60 @@ export class AuthPageObject {
|
|||||||
await this.page.fill('[name="repeatPassword"]', password);
|
await this.page.fill('[name="repeatPassword"]', password);
|
||||||
await this.page.click('[type="submit"]');
|
await this.page.click('[type="submit"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loginAsSuperAdmin(params: { next?: string }) {
|
||||||
|
await this.loginAsUser({
|
||||||
|
email: 'super-admin@makerkit.dev',
|
||||||
|
next: '/auth/verify',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Complete MFA verification
|
||||||
|
await this.submitMFAVerification(MFA_KEY);
|
||||||
|
await this.page.waitForURL(params.next ?? '/home');
|
||||||
|
}
|
||||||
|
|
||||||
|
async bootstrapUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
const client = createClient(
|
||||||
|
'http://127.0.0.1:54321',
|
||||||
|
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, error } = await client.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password: password || 'testingpassword',
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(`Failed to create user: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginAsUser(params: {
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
next?: string;
|
||||||
|
}) {
|
||||||
|
await this.goToSignIn(params.next);
|
||||||
|
|
||||||
|
await this.signIn({
|
||||||
|
email: params.email,
|
||||||
|
password: params.password || 'testingpassword',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.page.waitForURL(params.next ?? '**/home');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ test.describe('Auth flow', () => {
|
|||||||
|
|
||||||
await auth.visitConfirmEmailLink(email);
|
await auth.visitConfirmEmailLink(email);
|
||||||
|
|
||||||
await page.waitForURL('**/home');
|
await page.waitForURL('**/home', {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('will sign-in with the correct credentials', async ({ page }) => {
|
test('will sign-in with the correct credentials', async ({ page }) => {
|
||||||
@@ -43,7 +45,9 @@ test.describe('Auth flow', () => {
|
|||||||
password: 'password',
|
password: 'password',
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('**/home');
|
await page.waitForURL('**/home', {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
expect(page.url()).toContain('/home');
|
expect(page.url()).toContain('/home');
|
||||||
|
|
||||||
@@ -62,7 +66,9 @@ test.describe('Auth flow', () => {
|
|||||||
password: 'testingpassword',
|
password: 'testingpassword',
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('/home/settings');
|
await page.waitForURL('/home/settings', {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
await auth.signOut();
|
await auth.signOut();
|
||||||
|
|
||||||
@@ -75,17 +81,14 @@ test.describe('Protected routes', () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const auth = new AuthPageObject(page);
|
const auth = new AuthPageObject(page);
|
||||||
|
const path = '/home/settings';
|
||||||
|
|
||||||
await page.goto('/home/settings');
|
await page.goto(path);
|
||||||
|
|
||||||
await auth.signIn({
|
await auth.loginAsUser({
|
||||||
email: 'test@makerkit.dev',
|
email: 'test@makerkit.dev',
|
||||||
password: 'testingpassword',
|
next: path,
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('/home/settings');
|
|
||||||
|
|
||||||
expect(page.url()).toContain('/home/settings');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('will redirect to the sign-in page if not authenticated', async ({
|
test('will redirect to the sign-in page if not authenticated', async ({
|
||||||
@@ -115,7 +118,9 @@ test.describe('Last auth method tracking', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await auth.visitConfirmEmailLink(testEmail);
|
await auth.visitConfirmEmailLink(testEmail);
|
||||||
await page.waitForURL('**/home');
|
await page.waitForURL('**/home', {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
// Sign out
|
// Sign out
|
||||||
await auth.signOut();
|
await auth.signOut();
|
||||||
@@ -169,7 +174,9 @@ test.describe('Last auth method tracking', () => {
|
|||||||
password: 'password123',
|
password: 'password123',
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('**/home');
|
await page.waitForURL('**/home', {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
|
||||||
// Sign out and check the method is still tracked
|
// Sign out and check the method is still tracked
|
||||||
await auth.signOut();
|
await auth.signOut();
|
||||||
|
|||||||
@@ -13,22 +13,12 @@ test.describe('Password Reset Flow', () => {
|
|||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
email = auth.createRandomEmail();
|
email = auth.createRandomEmail();
|
||||||
|
|
||||||
await page.goto('/auth/sign-up');
|
auth.bootstrapUser({
|
||||||
|
|
||||||
await auth.signUp({
|
|
||||||
email,
|
email,
|
||||||
password: 'password',
|
password: 'password',
|
||||||
repeatPassword: 'password',
|
name: 'Test User',
|
||||||
});
|
});
|
||||||
|
|
||||||
await auth.visitConfirmEmailLink(email, {
|
|
||||||
deleteAfter: true,
|
|
||||||
subject: 'Confirm your email',
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.context().clearCookies();
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
await page.goto('/auth/password-reset');
|
await page.goto('/auth/password-reset');
|
||||||
|
|
||||||
await page.fill('[name="email"]', email);
|
await page.fill('[name="email"]', email);
|
||||||
@@ -59,13 +49,10 @@ test.describe('Password Reset Flow', () => {
|
|||||||
await page.waitForURL('/');
|
await page.waitForURL('/');
|
||||||
await page.goto('/auth/sign-in');
|
await page.goto('/auth/sign-in');
|
||||||
|
|
||||||
await auth.signIn({
|
await auth.loginAsUser({
|
||||||
email,
|
email,
|
||||||
password: newPassword,
|
password: newPassword,
|
||||||
});
|
next: '/home',
|
||||||
|
|
||||||
await page.waitForURL('/home', {
|
|
||||||
timeout: 2000,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { Page, expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
import { InvitationsPageObject } from './invitations.po';
|
import { InvitationsPageObject } from './invitations.po';
|
||||||
|
|
||||||
test.describe('Invitations', () => {
|
test.describe('Invitations', () => {
|
||||||
let page: Page;
|
|
||||||
let invitations: InvitationsPageObject;
|
let invitations: InvitationsPageObject;
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
page = await browser.newPage();
|
|
||||||
invitations = new InvitationsPageObject(page);
|
invitations = new InvitationsPageObject(page);
|
||||||
|
|
||||||
await invitations.setup();
|
await invitations.setup();
|
||||||
@@ -88,17 +86,12 @@ test.describe('Invitations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Full Invitation Flow', () => {
|
test.describe('Full Invitation Flow', () => {
|
||||||
let page: Page;
|
test('should invite users and let users accept an invite', async ({
|
||||||
let invitations: InvitationsPageObject;
|
page,
|
||||||
|
}) => {
|
||||||
test.beforeAll(async ({ browser }) => {
|
const invitations = new InvitationsPageObject(page);
|
||||||
page = await browser.newPage();
|
|
||||||
invitations = new InvitationsPageObject(page);
|
|
||||||
|
|
||||||
await invitations.setup();
|
await invitations.setup();
|
||||||
});
|
|
||||||
|
|
||||||
test('should invite users and let users accept an invite', async () => {
|
|
||||||
await invitations.navigateToMembers();
|
await invitations.navigateToMembers();
|
||||||
|
|
||||||
const invites = [
|
const invites = [
|
||||||
|
|||||||
@@ -15,11 +15,24 @@ export class TeamAccountsPageObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setup(params = this.createTeamName()) {
|
async setup(params = this.createTeamName()) {
|
||||||
const { email } = await this.auth.signUpFlow('/home');
|
const auth = new AuthPageObject(this.page);
|
||||||
|
|
||||||
|
const email = auth.createRandomEmail();
|
||||||
|
|
||||||
|
await auth.bootstrapUser({
|
||||||
|
email,
|
||||||
|
name: 'Test User',
|
||||||
|
});
|
||||||
|
|
||||||
|
await auth.loginAsUser({ email });
|
||||||
|
|
||||||
await this.createTeam(params);
|
await this.createTeam(params);
|
||||||
|
|
||||||
return { email, teamName: params.teamName, slug: params.slug };
|
return {
|
||||||
|
email: email,
|
||||||
|
teamName: params.teamName,
|
||||||
|
slug: params.slug,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getTeamFromSelector(teamName: string) {
|
getTeamFromSelector(teamName: string) {
|
||||||
@@ -81,11 +94,12 @@ export class TeamAccountsPageObject {
|
|||||||
async tryCreateTeam(teamName: string) {
|
async tryCreateTeam(teamName: string) {
|
||||||
await this.page.locator('[data-test="create-team-form"] input').fill('');
|
await this.page.locator('[data-test="create-team-form"] input').fill('');
|
||||||
await this.page.waitForTimeout(200);
|
await this.page.waitForTimeout(200);
|
||||||
await this.page.locator('[data-test="create-team-form"] input').fill(teamName);
|
|
||||||
|
|
||||||
return this.page.click(
|
await this.page
|
||||||
'[data-test="create-team-form"] button:last-child',
|
.locator('[data-test="create-team-form"] input')
|
||||||
);
|
.fill(teamName);
|
||||||
|
|
||||||
|
return this.page.click('[data-test="create-team-form"] button:last-child');
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTeam({ teamName, slug } = this.createTeamName()) {
|
async createTeam({ teamName, slug } = this.createTeamName()) {
|
||||||
@@ -150,13 +164,13 @@ export class TeamAccountsPageObject {
|
|||||||
await this.page.click('[data-test="role-selector-trigger"]');
|
await this.page.click('[data-test="role-selector-trigger"]');
|
||||||
await this.page.click(`[data-test="role-option-${newRole}"]`);
|
await this.page.click(`[data-test="role-option-${newRole}"]`);
|
||||||
|
|
||||||
// Click the confirm button
|
|
||||||
const click = this.page.click('[data-test="confirm-update-member-role"]');
|
|
||||||
|
|
||||||
// Wait for the update to complete and page to reload
|
// Wait for the update to complete and page to reload
|
||||||
const response = this.page.waitForURL('**/home/*/members');
|
const response = this.page.waitForResponse('**/members');
|
||||||
|
|
||||||
return Promise.all([click, response]);
|
return Promise.all([
|
||||||
|
this.page.click('[data-test="confirm-update-member-role"]'),
|
||||||
|
response,
|
||||||
|
]);
|
||||||
}).toPass();
|
}).toPass();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,15 +186,13 @@ export class TeamAccountsPageObject {
|
|||||||
// Complete OTP verification
|
// Complete OTP verification
|
||||||
await this.otp.completeOtpVerification(ownerEmail);
|
await this.otp.completeOtpVerification(ownerEmail);
|
||||||
|
|
||||||
// Click the confirm button
|
|
||||||
const click = this.page.click(
|
|
||||||
'[data-test="confirm-transfer-ownership-button"]',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for the transfer to complete and page to reload
|
// Wait for the transfer to complete and page to reload
|
||||||
const response = this.page.waitForURL('**/home/*/members');
|
const response = this.page.waitForResponse('**/members');
|
||||||
|
|
||||||
return Promise.all([click, response]);
|
return Promise.all([
|
||||||
|
this.page.click('[data-test="confirm-transfer-ownership-button"]'),
|
||||||
|
response,
|
||||||
|
]);
|
||||||
}).toPass();
|
}).toPass();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,13 +56,11 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
|
|||||||
|
|
||||||
await page.goto('/auth/sign-in');
|
await page.goto('/auth/sign-in');
|
||||||
|
|
||||||
await invitations.auth.signIn({
|
await invitations.auth.loginAsUser({
|
||||||
email: ownerEmail,
|
email: ownerEmail,
|
||||||
password: 'password',
|
next: '/home',
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('/home');
|
|
||||||
|
|
||||||
// Navigate to the team members page
|
// Navigate to the team members page
|
||||||
await page.goto(`/home/${slug}/members`);
|
await page.goto(`/home/${slug}/members`);
|
||||||
|
|
||||||
@@ -70,17 +68,13 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Team Accounts', () => {
|
test.describe('Team Accounts', () => {
|
||||||
let page: Page;
|
test.beforeEach(async ({ page }) => {
|
||||||
let teamAccounts: TeamAccountsPageObject;
|
const teamAccounts = new TeamAccountsPageObject(page);
|
||||||
|
await teamAccounts.setup();
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
teamAccounts = new TeamAccountsPageObject(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('user can update their team name (and slug)', async () => {
|
test('user can update their team name (and slug)', async ({ page }) => {
|
||||||
await teamAccounts.setup();
|
const teamAccounts = new TeamAccountsPageObject(page);
|
||||||
|
|
||||||
const { teamName, slug } = teamAccounts.createTeamName();
|
const { teamName, slug } = teamAccounts.createTeamName();
|
||||||
|
|
||||||
await teamAccounts.goToSettings();
|
await teamAccounts.goToSettings();
|
||||||
@@ -101,7 +95,7 @@ test.describe('Team Accounts', () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const teamAccounts = new TeamAccountsPageObject(page);
|
const teamAccounts = new TeamAccountsPageObject(page);
|
||||||
await teamAccounts.setup();
|
await teamAccounts.createTeam();
|
||||||
|
|
||||||
await teamAccounts.openAccountsSelector();
|
await teamAccounts.openAccountsSelector();
|
||||||
await page.click('[data-test="create-team-account-trigger"]');
|
await page.click('[data-test="create-team-account-trigger"]');
|
||||||
@@ -132,16 +126,16 @@ test.describe('Team Accounts', () => {
|
|||||||
await teamAccounts.tryCreateTeam('Test,Name');
|
await teamAccounts.tryCreateTeam('Test,Name');
|
||||||
await expectError();
|
await expectError();
|
||||||
|
|
||||||
await teamAccounts.tryCreateTeam('Test Name/')
|
await teamAccounts.tryCreateTeam('Test Name/');
|
||||||
await expectError();
|
await expectError();
|
||||||
|
|
||||||
await teamAccounts.tryCreateTeam('Test Name\\')
|
await teamAccounts.tryCreateTeam('Test Name\\');
|
||||||
await expectError();
|
await expectError();
|
||||||
|
|
||||||
await teamAccounts.tryCreateTeam('Test Name:')
|
await teamAccounts.tryCreateTeam('Test Name:');
|
||||||
await expectError();
|
await expectError();
|
||||||
|
|
||||||
await teamAccounts.tryCreateTeam('Test Name;')
|
await teamAccounts.tryCreateTeam('Test Name;');
|
||||||
await expectError();
|
await expectError();
|
||||||
|
|
||||||
await teamAccounts.tryCreateTeam('Test Name=');
|
await teamAccounts.tryCreateTeam('Test Name=');
|
||||||
@@ -225,14 +219,11 @@ test.describe('Team Member Role Management', () => {
|
|||||||
// Update the member's role to admin
|
// Update the member's role to admin
|
||||||
await teamAccounts.updateMemberRole(memberEmail, 'owner');
|
await teamAccounts.updateMemberRole(memberEmail, 'owner');
|
||||||
|
|
||||||
// Wait for the page to fully load after the update
|
await expect(
|
||||||
await page.waitForTimeout(1000);
|
page
|
||||||
|
.getByRole('row', { name: memberEmail })
|
||||||
// Verify the role was updated successfully
|
.locator('[data-test="member-role-badge"]'),
|
||||||
const updatedRoleBadge = page
|
).toHaveText('Owner');
|
||||||
.getByRole('row', { name: memberEmail })
|
|
||||||
.locator('[data-test="member-role-badge"]');
|
|
||||||
await expect(updatedRoleBadge).toHaveText('Owner');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +241,7 @@ test.describe('Team Ownership Transfer', () => {
|
|||||||
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
|
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
|
||||||
|
|
||||||
// Wait for the page to fully load after the transfer
|
// Wait for the page to fully load after the transfer
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Verify the transfer was successful by checking if the primary owner badge
|
// Verify the transfer was successful by checking if the primary owner badge
|
||||||
// is now on the new owner's row
|
// is now on the new owner's row
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { AuthPageObject } from '../authentication/auth.po';
|
|||||||
import { InvitationsPageObject } from '../invitations/invitations.po';
|
import { InvitationsPageObject } from '../invitations/invitations.po';
|
||||||
import { TeamAccountsPageObject } from './team-accounts.po';
|
import { TeamAccountsPageObject } from './team-accounts.po';
|
||||||
|
|
||||||
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
|
|
||||||
|
|
||||||
test.describe('Team Invitation with MFA Flow', () => {
|
test.describe('Team Invitation with MFA Flow', () => {
|
||||||
test('complete flow: test@makerkit.dev creates team, invites super-admin@makerkit.dev who accepts after MFA', async ({
|
test('complete flow: test@makerkit.dev creates team, invites super-admin@makerkit.dev who accepts after MFA', async ({
|
||||||
page,
|
page,
|
||||||
@@ -14,18 +12,12 @@ test.describe('Team Invitation with MFA Flow', () => {
|
|||||||
const teamAccounts = new TeamAccountsPageObject(page);
|
const teamAccounts = new TeamAccountsPageObject(page);
|
||||||
const invitations = new InvitationsPageObject(page);
|
const invitations = new InvitationsPageObject(page);
|
||||||
|
|
||||||
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
|
await auth.loginAsUser({
|
||||||
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
|
|
||||||
|
|
||||||
// Step 1: test@makerkit.dev creates a team and sends invitation
|
|
||||||
await page.goto('/auth/sign-in');
|
|
||||||
|
|
||||||
await auth.signIn({
|
|
||||||
email: 'test@makerkit.dev',
|
email: 'test@makerkit.dev',
|
||||||
password: 'testingpassword',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.waitForURL('/home');
|
const teamName = `test-team-${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
|
||||||
|
|
||||||
// Create a new team
|
// Create a new team
|
||||||
await teamAccounts.createTeam({
|
await teamAccounts.createTeam({
|
||||||
@@ -46,19 +38,24 @@ test.describe('Team Invitation with MFA Flow', () => {
|
|||||||
|
|
||||||
// Verify invitation was sent
|
// Verify invitation was sent
|
||||||
await expect(invitations.getInvitations()).toHaveCount(1);
|
await expect(invitations.getInvitations()).toHaveCount(1);
|
||||||
|
|
||||||
const invitationRow = invitations.getInvitationRow(
|
const invitationRow = invitations.getInvitationRow(
|
||||||
'super-admin@makerkit.dev',
|
'super-admin@makerkit.dev',
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(invitationRow).toBeVisible();
|
await expect(invitationRow).toBeVisible();
|
||||||
|
|
||||||
// Sign out test@makerkit.dev
|
await expect(async () => {
|
||||||
await auth.signOut();
|
// Sign out test@makerkit.dev
|
||||||
await page.waitForURL('/');
|
await auth.signOut();
|
||||||
|
|
||||||
// Step 2: super-admin@makerkit.dev signs in with MFA
|
await page.waitForURL('/', {
|
||||||
await page.context().clearCookies();
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
}).toPass();
|
||||||
|
|
||||||
await auth.visitConfirmEmailLink('super-admin@makerkit.dev');
|
await auth.visitConfirmEmailLink('super-admin@makerkit.dev');
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.locator('[data-test="existing-account-hint"]')
|
.locator('[data-test="existing-account-hint"]')
|
||||||
.getByRole('link', { name: 'Already have an account?' })
|
.getByRole('link', { name: 'Already have an account?' })
|
||||||
@@ -71,7 +68,7 @@ test.describe('Team Invitation with MFA Flow', () => {
|
|||||||
|
|
||||||
// Complete MFA verification
|
// Complete MFA verification
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
await auth.submitMFAVerification(MFA_KEY);
|
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);
|
||||||
}).toPass({
|
}).toPass({
|
||||||
intervals: [
|
intervals: [
|
||||||
500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000,
|
500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000,
|
||||||
@@ -91,6 +88,7 @@ test.describe('Team Invitation with MFA Flow', () => {
|
|||||||
// Step 4: Verify membership was successful
|
// Step 4: Verify membership was successful
|
||||||
// Open account selector to verify team is available
|
// Open account selector to verify team is available
|
||||||
await teamAccounts.openAccountsSelector();
|
await teamAccounts.openAccountsSelector();
|
||||||
|
|
||||||
const team = teamAccounts.getTeamFromSelector(teamName);
|
const team = teamAccounts.getTeamFromSelector(teamName);
|
||||||
|
|
||||||
await expect(team).toBeVisible();
|
await expect(team).toBeVisible();
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ export class TeamBillingPageObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
return this.teamAccounts.setup();
|
return this.teamAccounts.createTeam();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import { Page, expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { AuthPageObject } from '../authentication/auth.po';
|
||||||
import { TeamBillingPageObject } from './team-billing.po';
|
import { TeamBillingPageObject } from './team-billing.po';
|
||||||
|
|
||||||
test.describe('Team Billing', () => {
|
test.describe('Team Billing', () => {
|
||||||
let page: Page;
|
test('a team can subscribe to a plan', async ({ page }) => {
|
||||||
let po: TeamBillingPageObject;
|
const po = new TeamBillingPageObject(page);
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
await po.teamAccounts.setup();
|
||||||
page = await browser.newPage();
|
|
||||||
po = new TeamBillingPageObject(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a team can subscribe to a plan', async () => {
|
|
||||||
await po.setup();
|
|
||||||
await po.teamAccounts.goToBilling();
|
await po.teamAccounts.goToBilling();
|
||||||
|
|
||||||
await po.billing.selectPlan(0);
|
await po.billing.selectPlan(0);
|
||||||
@@ -23,7 +18,7 @@ test.describe('Team Billing', () => {
|
|||||||
await po.billing.stripe.submitForm();
|
await po.billing.stripe.submitForm();
|
||||||
|
|
||||||
await expect(po.billing.successStatus()).toBeVisible({
|
await expect(po.billing.successStatus()).toBeVisible({
|
||||||
timeout: 25_000,
|
timeout: 20_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
await po.billing.returnToBilling();
|
await po.billing.returnToBilling();
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
import { Page } from '@playwright/test';
|
import { Page } from '@playwright/test';
|
||||||
import { AuthPageObject } from '../authentication/auth.po';
|
|
||||||
import { BillingPageObject } from '../utils/billing.po';
|
import { BillingPageObject } from '../utils/billing.po';
|
||||||
|
|
||||||
export class UserBillingPageObject {
|
export class UserBillingPageObject {
|
||||||
private readonly auth: AuthPageObject;
|
|
||||||
public readonly billing: BillingPageObject;
|
public readonly billing: BillingPageObject;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(private readonly page: Page) {
|
||||||
this.auth = new AuthPageObject(page);
|
|
||||||
this.billing = new BillingPageObject(page);
|
this.billing = new BillingPageObject(page);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
async setup() {
|
|
||||||
await this.auth.signUpFlow('/home/billing');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { Page, expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { AuthPageObject } from '../authentication/auth.po';
|
||||||
import { UserBillingPageObject } from './user-billing.po';
|
import { UserBillingPageObject } from './user-billing.po';
|
||||||
|
|
||||||
test.describe('User Billing', () => {
|
test.describe('User Billing', () => {
|
||||||
let page: Page;
|
|
||||||
let po: UserBillingPageObject;
|
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
po = new UserBillingPageObject(page);
|
|
||||||
|
|
||||||
await po.setup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('user can subscribe to a plan', async ({ page }) => {
|
test('user can subscribe to a plan', async ({ page }) => {
|
||||||
|
const po = new UserBillingPageObject(page);
|
||||||
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
|
const email = auth.createRandomEmail();
|
||||||
|
|
||||||
|
await auth.bootstrapUser({
|
||||||
|
email,
|
||||||
|
name: 'Test Billing User',
|
||||||
|
});
|
||||||
|
|
||||||
|
await auth.loginAsUser({
|
||||||
|
email,
|
||||||
|
next: '/home/billing',
|
||||||
|
});
|
||||||
|
|
||||||
await po.billing.selectPlan(0);
|
await po.billing.selectPlan(0);
|
||||||
await po.billing.proceedToCheckout();
|
await po.billing.proceedToCheckout();
|
||||||
|
|
||||||
|
|||||||
10
apps/e2e/tests/utils/auth-state.ts
Normal file
10
apps/e2e/tests/utils/auth-state.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { cwd } from 'node:process';
|
||||||
|
|
||||||
|
export const AUTH_STATES = {
|
||||||
|
TEST_USER: join(cwd(), '.auth/test@makerkit.dev.json'),
|
||||||
|
OWNER_USER: join(cwd(), '.auth/owner@makerkit.dev.json'),
|
||||||
|
SUPER_ADMIN: join(cwd(), '.auth/super-admin@makerkit.dev.json'),
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AuthState = keyof typeof AUTH_STATES;
|
||||||
@@ -17,13 +17,15 @@ export class StripePageObject {
|
|||||||
}).toPass();
|
}).toPass();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fillForm(params: {
|
async fillForm(
|
||||||
billingName?: string;
|
params: {
|
||||||
cardNumber?: string;
|
billingName?: string;
|
||||||
expiry?: string;
|
cardNumber?: string;
|
||||||
cvc?: string;
|
expiry?: string;
|
||||||
billingCountry?: string;
|
cvc?: string;
|
||||||
} = {}) {
|
billingCountry?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const billingName = this.billingName();
|
const billingName = this.billingName();
|
||||||
const cardNumber = this.cardNumber();
|
const cardNumber = this.cardNumber();
|
||||||
const expiry = this.expiry();
|
const expiry = this.expiry();
|
||||||
@@ -38,7 +40,9 @@ export class StripePageObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
submitForm() {
|
submitForm() {
|
||||||
return this.getStripeCheckoutIframe().getByTestId('hosted-payment-submit-button').click();
|
return this.getStripeCheckoutIframe()
|
||||||
|
.locator('[data-testid="hosted-payment-submit-button"]')
|
||||||
|
.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
cardNumber() {
|
cardNumber() {
|
||||||
@@ -60,4 +64,4 @@ export class StripePageObject {
|
|||||||
billingCountry() {
|
billingCountry() {
|
||||||
return this.getStripeCheckoutIframe().locator('#billingCountry');
|
return this.getStripeCheckoutIframe().locator('#billingCountry');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,10 +220,6 @@ function ActionsDropdown({
|
|||||||
currentTeamAccountId: string;
|
currentTeamAccountId: string;
|
||||||
currentRoleHierarchy: number;
|
currentRoleHierarchy: number;
|
||||||
}) {
|
}) {
|
||||||
const [isRemoving, setIsRemoving] = useState(false);
|
|
||||||
const [isTransferring, setIsTransferring] = useState(false);
|
|
||||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
|
||||||
|
|
||||||
const isCurrentUser = member.user_id === currentUserId;
|
const isCurrentUser = member.user_id === currentUserId;
|
||||||
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
|
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
|
||||||
|
|
||||||
@@ -258,54 +254,42 @@ function ActionsDropdown({
|
|||||||
|
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<If condition={canUpdateRole}>
|
<If condition={canUpdateRole}>
|
||||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
<UpdateMemberRoleDialog
|
||||||
<Trans i18nKey={'teams:updateRole'} />
|
userId={member.user_id}
|
||||||
</DropdownMenuItem>
|
userRole={member.role}
|
||||||
|
teamAccountId={currentTeamAccountId}
|
||||||
|
userRoleHierarchy={currentRoleHierarchy}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trans i18nKey={'teams:updateRole'} />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</UpdateMemberRoleDialog>
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
<If condition={permissions.canTransferOwnership}>
|
<If condition={permissions.canTransferOwnership}>
|
||||||
<DropdownMenuItem onClick={() => setIsTransferring(true)}>
|
<TransferOwnershipDialog
|
||||||
<Trans i18nKey={'teams:transferOwnership'} />
|
targetDisplayName={member.name ?? member.email}
|
||||||
</DropdownMenuItem>
|
accountId={member.account_id}
|
||||||
|
userId={member.user_id}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trans i18nKey={'teams:transferOwnership'} />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</TransferOwnershipDialog>
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
<If condition={canRemoveFromAccount}>
|
<If condition={canRemoveFromAccount}>
|
||||||
<DropdownMenuItem onClick={() => setIsRemoving(true)}>
|
<RemoveMemberDialog
|
||||||
<Trans i18nKey={'teams:removeMember'} />
|
teamAccountId={currentTeamAccountId}
|
||||||
</DropdownMenuItem>
|
userId={member.user_id}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
|
<Trans i18nKey={'teams:removeMember'} />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</RemoveMemberDialog>
|
||||||
</If>
|
</If>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<If condition={isRemoving}>
|
|
||||||
<RemoveMemberDialog
|
|
||||||
isOpen
|
|
||||||
setIsOpen={setIsRemoving}
|
|
||||||
teamAccountId={currentTeamAccountId}
|
|
||||||
userId={member.user_id}
|
|
||||||
/>
|
|
||||||
</If>
|
|
||||||
|
|
||||||
<If condition={isUpdatingRole}>
|
|
||||||
<UpdateMemberRoleDialog
|
|
||||||
isOpen
|
|
||||||
setIsOpen={setIsUpdatingRole}
|
|
||||||
userId={member.user_id}
|
|
||||||
userRole={member.role}
|
|
||||||
teamAccountId={currentTeamAccountId}
|
|
||||||
userRoleHierarchy={currentRoleHierarchy}
|
|
||||||
/>
|
|
||||||
</If>
|
|
||||||
|
|
||||||
<If condition={isTransferring}>
|
|
||||||
<TransferOwnershipDialog
|
|
||||||
isOpen
|
|
||||||
setIsOpen={setIsTransferring}
|
|
||||||
targetDisplayName={member.name ?? member.email}
|
|
||||||
accountId={member.account_id}
|
|
||||||
userId={member.user_id}
|
|
||||||
/>
|
|
||||||
</If>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
} from '@kit/ui/alert-dialog';
|
} from '@kit/ui/alert-dialog';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { If } from '@kit/ui/if';
|
import { If } from '@kit/ui/if';
|
||||||
@@ -17,18 +18,17 @@ import { Trans } from '@kit/ui/trans';
|
|||||||
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
|
import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions';
|
||||||
|
|
||||||
export function RemoveMemberDialog({
|
export function RemoveMemberDialog({
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
|
||||||
teamAccountId,
|
teamAccountId,
|
||||||
userId,
|
userId,
|
||||||
}: {
|
children,
|
||||||
isOpen: boolean;
|
}: React.PropsWithChildren<{
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
|
||||||
teamAccountId: string;
|
teamAccountId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||||
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
@@ -40,11 +40,7 @@ export function RemoveMemberDialog({
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|
||||||
<RemoveMemberForm
|
<RemoveMemberForm accountId={teamAccountId} userId={userId} />
|
||||||
setIsOpen={setIsOpen}
|
|
||||||
accountId={teamAccountId}
|
|
||||||
userId={userId}
|
|
||||||
/>
|
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
@@ -53,11 +49,9 @@ export function RemoveMemberDialog({
|
|||||||
function RemoveMemberForm({
|
function RemoveMemberForm({
|
||||||
accountId,
|
accountId,
|
||||||
userId,
|
userId,
|
||||||
setIsOpen,
|
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<boolean>();
|
const [error, setError] = useState<boolean>();
|
||||||
@@ -66,8 +60,6 @@ function RemoveMemberForm({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await removeMemberFromAccountAction({ accountId, userId });
|
await removeMemberFromAccountAction({ accountId, userId });
|
||||||
|
|
||||||
setIsOpen(false);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
} from '@kit/ui/alert-dialog';
|
} from '@kit/ui/alert-dialog';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { Form } from '@kit/ui/form';
|
import { Form } from '@kit/ui/form';
|
||||||
@@ -26,20 +27,20 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner
|
|||||||
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
|
import { transferOwnershipAction } from '../../server/actions/team-members-server-actions';
|
||||||
|
|
||||||
export function TransferOwnershipDialog({
|
export function TransferOwnershipDialog({
|
||||||
isOpen,
|
children,
|
||||||
setIsOpen,
|
|
||||||
targetDisplayName,
|
targetDisplayName,
|
||||||
accountId,
|
accountId,
|
||||||
userId,
|
userId,
|
||||||
}: {
|
}: {
|
||||||
isOpen: boolean;
|
children: React.ReactNode;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
|
||||||
accountId: string;
|
accountId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
targetDisplayName: string;
|
targetDisplayName: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||||
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
@@ -55,7 +56,6 @@ export function TransferOwnershipDialog({
|
|||||||
accountId={accountId}
|
accountId={accountId}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
targetDisplayName={targetDisplayName}
|
targetDisplayName={targetDisplayName}
|
||||||
setIsOpen={setIsOpen}
|
|
||||||
/>
|
/>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
@@ -66,12 +66,10 @@ function TransferOrganizationOwnershipForm({
|
|||||||
accountId,
|
accountId,
|
||||||
userId,
|
userId,
|
||||||
targetDisplayName,
|
targetDisplayName,
|
||||||
setIsOpen,
|
|
||||||
}: {
|
}: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
targetDisplayName: string;
|
targetDisplayName: string;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<boolean>();
|
const [error, setError] = useState<boolean>();
|
||||||
@@ -121,8 +119,6 @@ function TransferOrganizationOwnershipForm({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await transferOwnershipAction(data);
|
await transferOwnershipAction(data);
|
||||||
|
|
||||||
setIsOpen(false);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from '@kit/ui/dialog';
|
} from '@kit/ui/dialog';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@@ -33,22 +34,20 @@ import { RolesDataProvider } from './roles-data-provider';
|
|||||||
type Role = string;
|
type Role = string;
|
||||||
|
|
||||||
export function UpdateMemberRoleDialog({
|
export function UpdateMemberRoleDialog({
|
||||||
isOpen,
|
children,
|
||||||
setIsOpen,
|
|
||||||
userId,
|
userId,
|
||||||
teamAccountId,
|
teamAccountId,
|
||||||
userRole,
|
userRole,
|
||||||
userRoleHierarchy,
|
userRoleHierarchy,
|
||||||
}: {
|
}: React.PropsWithChildren<{
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
teamAccountId: string;
|
teamAccountId: string;
|
||||||
userRole: Role;
|
userRole: Role;
|
||||||
userRoleHierarchy: number;
|
userRoleHierarchy: number;
|
||||||
}) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
@@ -63,7 +62,6 @@ export function UpdateMemberRoleDialog({
|
|||||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
<UpdateMemberForm
|
<UpdateMemberForm
|
||||||
setIsOpen={setIsOpen}
|
|
||||||
userId={userId}
|
userId={userId}
|
||||||
teamAccountId={teamAccountId}
|
teamAccountId={teamAccountId}
|
||||||
userRole={userRole}
|
userRole={userRole}
|
||||||
@@ -80,13 +78,11 @@ function UpdateMemberForm({
|
|||||||
userId,
|
userId,
|
||||||
userRole,
|
userRole,
|
||||||
teamAccountId,
|
teamAccountId,
|
||||||
setIsOpen,
|
|
||||||
roles,
|
roles,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
userId: string;
|
userId: string;
|
||||||
userRole: Role;
|
userRole: Role;
|
||||||
teamAccountId: string;
|
teamAccountId: string;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
}>) {
|
}>) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
@@ -101,8 +97,6 @@ function UpdateMemberForm({
|
|||||||
userId,
|
userId,
|
||||||
role,
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsOpen(false);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
}
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -126,6 +126,9 @@ importers:
|
|||||||
'@playwright/test':
|
'@playwright/test':
|
||||||
specifier: ^1.55.0
|
specifier: ^1.55.0
|
||||||
version: 1.55.0
|
version: 1.55.0
|
||||||
|
'@supabase/supabase-js':
|
||||||
|
specifier: 2.57.4
|
||||||
|
version: 2.57.4
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.5.2
|
specifier: ^24.5.2
|
||||||
version: 24.5.2
|
version: 24.5.2
|
||||||
|
|||||||
Reference in New Issue
Block a user