diff --git a/.cursor/rules/end-to-end-tests/e2e-tests.mdc b/.cursor/rules/end-to-end-tests/e2e-tests.mdc new file mode 100644 index 0000000000..0b0ebea9a1 --- /dev/null +++ b/.cursor/rules/end-to-end-tests/e2e-tests.mdc @@ -0,0 +1,262 @@ +--- +description: Rules for end-to-end tests +globs: */tests/e2e/** +alwaysApply: false +--- +# End-to-End Tests + +These rules outline the structure, patterns, and best practices for writing end-to-end tests. + +## Implementation + +1. Use `[CLI_ALIAS] e2e` with these option categories to optimize test execution: + - Test filtering: `--smoke`, `--include-slow`, search terms (e.g., `"@smoke"`, `"smoke"`, `"user"`, `"localization"`), `--browser` + - Change scoping: `--last-failed`, `--only-changed` + - Flaky test detection: `--repeat-each`, `--retries`, `--stop-on-first-failure` + - Performance: `--debug-timings` shows step execution times with color coding + +2. Test Search and Filtering: + - Search by test tags: `[CLI_ALIAS] e2e "@smoke"` or `[CLI_ALIAS] e2e "smoke"` (both work the same) + - Search by test content: `[CLI_ALIAS] e2e "user"` (finds tests with "user" in title or content) + - Search by filename: `[CLI_ALIAS] e2e "localization"` (finds localization-flows.spec.ts) + - Search by specific file: `[CLI_ALIAS] e2e "user-management-flows.spec.ts"` + - Multiple search terms: `[CLI_ALIAS] e2e "user" "management"` + - The CLI automatically detects which self-contained systems contain matching tests and only runs those + +3. Test-Driven Debugging Process: + - Focus on one failing test at a time and make it pass before moving to the next. + - Ensure tests use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()`. + - Consider if root causes can be fixed in the application code, and fix application bugs rather than masking them with test workarounds. + +4. Organize tests in a consistent file structure: + - All e2e test files must be located in `[self-contained-system]/WebApp/tests/e2e/` folder (e.g., `application/account-management/WebApp/tests/e2e/`). + - All test files use the `*-flows.spec.ts` naming convention (e.g., `login-flows.spec.ts`, `signup-flows.spec.ts`, `user-management-flows.spec.ts`). + - Top-level describe blocks must use only these 3 approved tags: `test.describe("@smoke", () => {})`, `test.describe("@comprehensive", () => {})`, `test.describe("@slow", () => {})`. + - `@smoke` tests: + - Critical tests run on deployment of any self-contained system. + - Should be comprehensive scenarios that test core user journeys. + - Keep tests focused on specific flows to reduce fragility while maintaining coverage. + - Focus on must-work functionality with extensive validation steps. + - Include boundary cases and error handling within the same test scenario. + - Avoid testing the same functionality multiple times across different tests. + + - `@comprehensive` tests: + - Thorough tests run when a specific self-contained system is deployed. + - Focus on edge cases, error conditions, and less common scenarios. + - Test specific features in depth with various input combinations. + - Include tests for concurrency, validation rules, accessibility, etc. + - Group related edge cases together to reduce test count while maintaining coverage. + + - `@slow` tests: + - Optional and run only ad-hoc using `--include-slow` flag. + - Any tests that require waiting like `waitForTimeout` (e.g., for OTP timeouts) must be marked as `@slow`. + - Include tests for rate limiting with actual wait times, session timeouts, etc. + - Use `test.setTimeout()` at the individual test level based on actual wait times needed. + +5. Write clear test descriptions and documentation: + - Test descriptions must accurately reflect what the test covers and be kept in sync with test implementation. + - Use descriptive test names that clearly indicate the functionality being tested (e.g., "should handle single and bulk user deletion workflows with dashboard integration"). + - Include JSDoc comments above complex tests listing all major features/scenarios covered. + - When adding new functionality to existing tests, update both the test description and JSDoc comments to reflect changes. + +6. Structure each test with step decorators and proper monitoring: + - All tests must start with `const context = createTestContext(page);` for proper error monitoring. + - Use step decorators: `await step("Complete signup & verify account creation")(async () => { /* test logic */ })();` + - Step naming conventions: + - Always follow "[Business action + details] & [expected outcome]" pattern. + - Use business action verbs like "Sign up", "Login", "Invite", "Rename", "Update", "Delete", "Create", "Submit". + - Never use test/assertion prefixes like "Test", "Verify", "Check", "Validate", "Ensure"; use descriptive business actions instead. + - Every step must include an action (arrange/act) followed by assertions, not pure assertion steps. + - Step structure: + - Use blank lines to separate arrange/act/assert sections within steps. + - Keep shared variable declarations outside steps when used across multiple steps. + - Use section headers with `// === SECTION NAME ===` to group related steps. + - Add JSDoc comments for complex test workflows. + - Use semantic selectors: `page.getByRole("button", { name: "Submit" })`, `page.getByText("Welcome")`, `page.getByLabel("Email")`. + - Assert side effects immediately after actions using `expectToastMessage`, `expectValidationError`, `expectNetworkErrors`. + - Form validation pattern: Use `await blurActiveElement(page);` when updating a textbox the second time before submitting a form to trigger validation. + +7. Timeout Configuration: + - Always use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()`. + - Never add timeouts to `.click()`, `.waitForSelector()`, etc. + - Global timeout configuration is handled in the shared Playwright. Don't change this. + +8. Write deterministic tests - This is critical for reliable testing: + - Each test should have a clear, linear flow of actions and assertions. + - Never use if statements, custom error handling, or try/catch blocks in tests. + - Never use regular expressions in tests; use simple string matching instead. + +9. What to test: + - Enter invalid values, such as empty strings, only whitespace characters, long strings, negative numbers, Unicode, etc. + - Tooltips, keyboard navigation, accessibility, validation messages, translations, responsiveness, etc. + +10. Test Fixtures and Page Management: + - Use appropriate fixtures: `{ page }` for basic tests, `{ anonymousPage }` for tests with existing tenant/owner but not logged in, `{ ownerPage }`, `{ adminPage }`, `{ memberPage }` for authenticated tests. + - Destructure anonymous page data: `const { page, tenant } = anonymousPage; const existingUser = tenant.owner;` + - Pre-logged in users (`ownerPage`, `adminPage`, `memberPage`) are isolated between workers and will not conflict between tests. + - When using pre-logged in users, do not put the tenant or user into an invalid state that could affect other tests. + +11. Test Data and Constants: + - Use underscore separators: `const timeout = 30_000; // 30 seconds` + - Generate unique data: `const email = uniqueEmail();` + - Use faker.js to generate realistic test data: `const firstName = faker.person.firstName(); const email = faker.internet.email();` + - Long string testing: `const longEmail = \`${"a".repeat(90)}@example.com\`; // 101 characters total` + +12. Memory Management in E2E Tests: + - Playwright automatically handles browser context cleanup after tests + - Manual cleanup steps are unnecessary - focus on test clarity over micro-optimizations + - E2E test suites have minimal memory leak concerns due to their limited scope and duration + +## Examples + +### ✅ Good Step Naming Examples +```typescript +// ✅ DO: Business action + details & expected outcome +await step("Submit invalid email & verify validation error")(async () => { + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); + + await expectValidationError(context, "Invalid email."); +})(); + +await step("Sign up with valid credentials & verify account creation")(async () => { + await page.getByRole("button", { name: "Submit" }).click(); + + await expect(page.getByText("Welcome")).toBeVisible(); +})(); + +await step("Update user role to admin & verify permission change")(async () => { + const userRow = page.locator("tbody tr").first(); + + await userRow.getByLabel("User actions").click(); + await page.getByRole("menuitem", { name: "Change role" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Change user role" })).toBeVisible(); +})(); +``` + +### ❌ Bad Step Naming Examples +```typescript +// ❌ DON'T: Pure assertion steps without actions +await step("Verify button is visible")(async () => { + await expect(page.getByRole("button")).toBeVisible(); // No action, only assertion +})(); + +// ❌ DON'T: Using test/assertion prefixes +await step("Check user permissions")(async () => { // "Check" is assertion prefix + await expect(page.getByText("Admin")).toBeVisible(); +})(); + +await step("Validate form state")(async () => { // "Validate" is assertion prefix + await expect(page.getByRole("textbox")).toBeEmpty(); +})(); + +await step("Ensure user is deleted")(async () => { // "Ensure" is assertion prefix + await expect(page.getByText("user@example.com")).not.toBeVisible(); +})(); +``` + +### ✅ Complete Test Example +```typescript +import { step } from "@shared/e2e/utils/step-decorator"; +import { expectValidationError, blurActiveElement, createTestContext } from "@shared/e2e/utils/test-assertions"; +import { testUser } from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + test("should complete signup with validation", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Submit invalid email & verify validation error")(async () => { + await page.goto("/signup"); + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); // ✅ DO: Trigger validation when updating textbox second time + + await expectValidationError(context, "Invalid email."); + })(); + + await step("Sign up with valid email & verify verification redirect")(async () => { + await page.getByLabel("Email").fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/verify"); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should handle user management with pre-logged owner", async ({ ownerPage }) => { + createTestContext(ownerPage); // ✅ DO: Create context for pre-logged users + + await step("Access user management & verify owner permissions")(async () => { + await ownerPage.getByRole("button", { name: "Users" }).click(); + + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30_000; // 30 seconds + const codeValidationTimeout = 60_000; // 5 minutes + const sessionTimeout = codeValidationTimeout + 60_000; // 6 minutes + + test("should handle user logout after to many login attempts", async ({ page }) => { // ✅ DO: use new page, when testing e.g. account lockout + test.setTimeout(sessionTimeout); // ✅ DO: Set timeout based on actual wait times + const context = createTestContext(page); + + // ... + + await step("Wait for code expiration & verify timeout behavior")(async () => { + await page.goto("/login/verify"); + await page.waitForTimeout(codeValidationTimeout); // ✅ DO: Use actual waits in @slow tests + + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + })(); + }); +}); +``` + +```typescript +test.describe("@security", () => { // ❌ DON'T: Don't invent new tags - use @smoke, @comprehensive, @slow only + test("should handle login", async ({ page }) => { + // ❌ DON'T: Skip createTestContext(page); step + page.setDefaultTimeout(5000); // ❌ DON'T: Set timeouts manually - use global config + + // ❌ DON'T: Use test/assertion prefixes in step descriptions + await step("Test login functionality")(async () => { // ❌ Should be "Submit login form & verify authentication" + await step("Verify button is visible")(async () => { // ❌ Should be "Navigate to page & verify button is visible" + await step("Check user permissions")(async () => { // ❌ Should be "Click user menu & verify permissions" + if (page.url().includes("/login/verify")) { // ❌ DON'T: Add conditional logic - tests should be linear + await page.waitForTimeout(2000); // ❌ DON'T: Add manual timeouts + // Continue with verification... // ❌ DON'T: Write verbose explanatory comments + } + + await page.click("#submit-btn"); // ❌ DON'T: Use CSS selectors - use semantic selectors + + // ❌ DON'T: Skip assertions for side effects + })(); + + // ❌ DON'T: Use regular expressions - use simple string matching instead + await expect(page.getByText(/welcome.*home/i)).toBeVisible(); // ❌ Should be: page.getByText("Welcome home") + await expect(page.locator('input[name*="email"]')).toBeFocused(); // ❌ Should be: page.getByLabel("Email") + }); + + // ❌ DON'T: Place assertions outside test functions + expect(page.url().includes("/admin") || page.url().includes("/login")).toBeTruthy(); // ❌ DON'T: Use ambiguous assertions + + // ❌ DON'T: Use try/catch to handle flaky behavior - makes tests unreliable + try { + await page.waitForLoadState("networkidle"); // ❌ DON'T: Add timeout logic in tests + await page.getByRole("button", { name: "Submit" }).click({ timeout: 1000 }); // ❌ DON'T: Add timeouts to actions + } catch (error) { + await page.waitForTimeout(1000); // ❌ DON'T: Add manual waits + console.log("Retrying..."); // ❌ DON'T: Add custom error handling + } +}); + +// ❌ DON'T: Create tests without proper organization +test("isolated test without describe block", async ({ page }) => { + // ❌ DON'T: Violates organization rules +}); +``` diff --git a/.cursor/rules/workflows/create-e2e-tests.mdc b/.cursor/rules/workflows/create-e2e-tests.mdc new file mode 100644 index 0000000000..cc1091629e --- /dev/null +++ b/.cursor/rules/workflows/create-e2e-tests.mdc @@ -0,0 +1,64 @@ +--- +description: Workflow for creating end-to-end tests +globs: +alwaysApply: false +--- +# E2E Testing Workflow + +This workflow guides you through the process of creating comprehensive end-to-end tests for specific features like login and signup. It focuses on identifying what tests to write, planning complex scenarios, and ensuring tests follow the established conventions. + +## Workflow + +1. Understand the feature under test: + - Study the frontend components and their interactions. + - Review API endpoints and authentication flows. + - Understand validation rules and error handling. + - Identify key user interactions and expected behaviors. + +2. Use Browser MCP to explore the webapp functionality: + - Navigate to the application: `mcp0_browser_navigate({ url: "https://localhost:9000" })`. + - Interact with the feature manually to understand user flows. + - Take snapshots to identify UI elements and their structure. + - Document key interactions and expected behaviors. + - Note any edge cases or potential issues discovered during exploration. + +3. Review existing test examples: + - Read [End-to-End Tests](/.cursor/rules/end-to-end-tests/e2e-tests.mdc) for detailed information. + - Examine [signup.spec.ts](/application/account-management/WebApp/tests/e2e/signup.spec.ts) and [login.spec.ts](/application/account-management/WebApp/tests/e2e/login.spec.ts) for inspiration. + - Note the structure, assertions, test organization, and the "Act & Assert:" comment format. + +4. Plan comprehensive test scenarios: + - Identify standard user journeys through the feature. + - Plan for complex multi-session scenarios like: + - Concurrent sessions: What happens when a user has two tabs open? + - Cross-session state changes: What happens when state changes in one session affect another? + - Authentication conflicts: How does the system handle authentication changes across sessions? + - Form submissions across sessions: What happens with concurrent form submissions? + - Antiforgery token handling: How are antiforgery tokens managed across tabs? + - Browser navigation: Back/forward buttons, refresh, direct URL access. + - Network conditions: Slow connections, disconnections during operations. + - Input validation: Boundary values, special characters, extremely long inputs. + - Accessibility: Keyboard navigation, screen reader compatibility. + - Localization: Testing with different languages and formats. + +5. Categorize tests appropriately: + - `@smoke`: Essential functionality that will run on deployment of any system. + - Create one comprehensive smoke.spec.ts per self-contained system. + - Test complete user journeys: signup → profile setup → invite users → manage roles → tenant settings → logout. + - Include validation errors, retries, and recovery scenarios within the journey. + - `@comprehensive`: More thorough tests covering edge cases that will run on deployment of the system under test. + - Focus on specific feature areas with deep testing of edge cases. + - Group related scenarios to minimize test count while maximizing coverage. + - `@slow`: Tests involving timeouts or waiting periods that will run ad-hoc, when features under test are changed. + +6. Create or update test structure: + - For smoke tests: Create/update `application/[scs-name]/WebApp/tests/e2e/smoke.spec.ts`. + - For comprehensive tests: Create feature-specific files like `user-management.spec.ts`, `authentication.spec.ts`. + - Avoid creating many small, isolated tests - prefer comprehensive scenarios that test multiple aspects. + +## Key principles + +- Comprehensive coverage: Test all critical paths and important edge cases. +- Follow conventions: Adhere to the established patterns in [End-to-End Tests](/.cursor/rules/end-to-end-tests/e2e-tests.mdc). +- Clear organization: Properly categorize tests and use descriptive names. +- Realistic user journeys: Test scenarios that reflect actual user behavior. diff --git a/.cursor/rules/workflows/implement-product-increment.mdc b/.cursor/rules/workflows/implement-product-increment.mdc index 42aef51241..c8adbf7d50 100644 --- a/.cursor/rules/workflows/implement-product-increment.mdc +++ b/.cursor/rules/workflows/implement-product-increment.mdc @@ -17,10 +17,14 @@ Follow these steps which describe in detail how you must implement the tasks in Before implementing each task, review the relevant rules thoroughly: - - For **backend tasks**: - - Review all the [Backend](mdc:.cursor/rules/backend) rule files. - - For **frontend tasks**: - - Review all the [Frontend](mdc:.cursor/rules/frontend) rule files. +- For **backend tasks**: + - Review all the [Backend](mdc:.cursor/rules/backend) rule files. +- For **frontend tasks**: + - Review all the [Frontend](mdc:.cursor/rules/frontend) rule files. +- For **end-to-end tests**: + - Review the [E2E Testing Workflow](mdc:.cursor/rules/workflows/create-e2e-tests.mdc) and all the [End-to-End Tests](mdc:.cursor/rules/end-to-end-tests) rule files. +- For **Developer CLI commands**: + - Review all the [Developer CLI](mdc:.cursor/rules/developer-cli) rule files. These rules define the conventions and must be strictly adhered to during implementation. diff --git a/.gitignore b/.gitignore index 82d719f94b..31af6d9aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -400,3 +400,10 @@ dist/ # Git submodules .gitmodules + +# Playwright E2E testing artifacts +test-results/ +playwright-report/ +**/playwright/.cache/ +**/.auth/ + diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8ac133d7a7..579e953ada 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,9 @@ { "recommendations": [ + "biomejs.biome", "bradlc.vscode-tailwindcss", "ms-azuretools.vscode-bicep", - "github.vscode-github-actions", - "biomejs.biome", + "ms-playwright.playwright", + "github.vscode-github-actions" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..8893407e94 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,64 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run All Playwright Tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/application/node_modules/@playwright/test/cli.js", + "args": ["test"], + "cwd": "${workspaceFolder}/application/account-management/WebApp/tests", + "env": { + "PUBLIC_URL": "https://localhost:9000" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Run Smoke Tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/application/node_modules/@playwright/test/cli.js", + "args": ["test", "--grep", "@smoke"], + "cwd": "${workspaceFolder}/application/account-management/WebApp/tests", + "env": { + "PUBLIC_URL": "https://localhost:9000" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Debug Current Test (Chrome)", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/application/node_modules/@playwright/test/cli.js", + "args": ["test", "${relativeFile}", "--headed", "--project=chromium", "--timeout=0"], + "cwd": "${workspaceFolder}/application/account-management/WebApp/tests", + "env": { + "PUBLIC_URL": "https://localhost:9000", + "PWDEBUG": "0" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "sourceMaps": true, + "smartStep": true, + "skipFiles": [ + "/**", + "**/node_modules/**" + ] + }, + { + "name": "Debug with Playwright Inspector", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/application/node_modules/@playwright/test/cli.js", + "args": ["test", "${relativeFile}", "--debug", "--project=chromium"], + "cwd": "${workspaceFolder}/application/account-management/WebApp/tests", + "env": { + "PUBLIC_URL": "https://localhost:9000" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen" + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f608d04765..13797a3e50 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,7 +27,8 @@ "editor.defaultFormatter": "biomejs.biome" }, "files.associations": { - "*.css": "tailwindcss" + "*.css": "tailwindcss", + "*.spec.ts": "typescript" }, "githubIssues.issueBranchTitle": "${issueNumber}-${sanitizedLowercaseIssueTitle}", "githubPullRequests.assignCreated": "${user}", @@ -35,4 +36,6 @@ "biome.lspBin": "./application/node_modules/.bin/biome", "biome.searchInPath": false, "biome.enabled": true, + "playwright.projectDir": "application/End2EndTests", + "playwright.showTrace": true, } diff --git a/.windsurf/rules/end-to-end-tests/e2e-tests.md b/.windsurf/rules/end-to-end-tests/e2e-tests.md new file mode 100644 index 0000000000..4a4fc00b0a --- /dev/null +++ b/.windsurf/rules/end-to-end-tests/e2e-tests.md @@ -0,0 +1,263 @@ +--- +trigger: glob +globs: */tests/e2e/** +description: Rules for end-to-end tests +--- + +# End-to-End Tests + +These rules outline the structure, patterns, and best practices for writing end-to-end tests. + +## Implementation + +1. Use `[CLI_ALIAS] e2e` with these option categories to optimize test execution: + - Test filtering: `--smoke`, `--include-slow`, search terms (e.g., `"@smoke"`, `"smoke"`, `"user"`, `"localization"`), `--browser` + - Change scoping: `--last-failed`, `--only-changed` + - Flaky test detection: `--repeat-each`, `--retries`, `--stop-on-first-failure` + - Performance: `--debug-timings` shows step execution times with color coding + +2. Test Search and Filtering: + - Search by test tags: `[CLI_ALIAS] e2e "@smoke"` or `[CLI_ALIAS] e2e "smoke"` (both work the same) + - Search by test content: `[CLI_ALIAS] e2e "user"` (finds tests with "user" in title or content) + - Search by filename: `[CLI_ALIAS] e2e "localization"` (finds localization-flows.spec.ts) + - Search by specific file: `[CLI_ALIAS] e2e "user-management-flows.spec.ts"` + - Multiple search terms: `[CLI_ALIAS] e2e "user" "management"` + - The CLI automatically detects which self-contained systems contain matching tests and only runs those + +3. Test-Driven Debugging Process: + - Focus on one failing test at a time and make it pass before moving to the next. + - Ensure tests use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()`. + - Consider if root causes can be fixed in the application code, and fix application bugs rather than masking them with test workarounds. + +4. Organize tests in a consistent file structure: + - All e2e test files must be located in `[self-contained-system]/WebApp/tests/e2e/` folder (e.g., `application/account-management/WebApp/tests/e2e/`). + - All test files use the `*-flows.spec.ts` naming convention (e.g., `login-flows.spec.ts`, `signup-flows.spec.ts`, `user-management-flows.spec.ts`). + - Top-level describe blocks must use only these 3 approved tags: `test.describe("@smoke", () => {})`, `test.describe("@comprehensive", () => {})`, `test.describe("@slow", () => {})`. + - `@smoke` tests: + - Critical tests run on deployment of any self-contained system. + - Should be comprehensive scenarios that test core user journeys. + - Keep tests focused on specific flows to reduce fragility while maintaining coverage. + - Focus on must-work functionality with extensive validation steps. + - Include boundary cases and error handling within the same test scenario. + - Avoid testing the same functionality multiple times across different tests. + + - `@comprehensive` tests: + - Thorough tests run when a specific self-contained system is deployed. + - Focus on edge cases, error conditions, and less common scenarios. + - Test specific features in depth with various input combinations. + - Include tests for concurrency, validation rules, accessibility, etc. + - Group related edge cases together to reduce test count while maintaining coverage. + + - `@slow` tests: + - Optional and run only ad-hoc using `--include-slow` flag. + - Any tests that require waiting like `waitForTimeout` (e.g., for OTP timeouts) must be marked as `@slow`. + - Include tests for rate limiting with actual wait times, session timeouts, etc. + - Use `test.setTimeout()` at the individual test level based on actual wait times needed. + +5. Write clear test descriptions and documentation: + - Test descriptions must accurately reflect what the test covers and be kept in sync with test implementation. + - Use descriptive test names that clearly indicate the functionality being tested (e.g., "should handle single and bulk user deletion workflows with dashboard integration"). + - Include JSDoc comments above complex tests listing all major features/scenarios covered. + - When adding new functionality to existing tests, update both the test description and JSDoc comments to reflect changes. + +6. Structure each test with step decorators and proper monitoring: + - All tests must start with `const context = createTestContext(page);` for proper error monitoring. + - Use step decorators: `await step("Complete signup & verify account creation")(async () => { /* test logic */ })();` + - Step naming conventions: + - Always follow "[Business action + details] & [expected outcome]" pattern. + - Use business action verbs like "Sign up", "Login", "Invite", "Rename", "Update", "Delete", "Create", "Submit". + - Never use test/assertion prefixes like "Test", "Verify", "Check", "Validate", "Ensure"; use descriptive business actions instead. + - Every step must include an action (arrange/act) followed by assertions, not pure assertion steps. + - Step structure: + - Use blank lines to separate arrange/act/assert sections within steps. + - Keep shared variable declarations outside steps when used across multiple steps. + - Use section headers with `// === SECTION NAME ===` to group related steps. + - Add JSDoc comments for complex test workflows. + - Use semantic selectors: `page.getByRole("button", { name: "Submit" })`, `page.getByText("Welcome")`, `page.getByLabel("Email")`. + - Assert side effects immediately after actions using `expectToastMessage`, `expectValidationError`, `expectNetworkErrors`. + - Form validation pattern: Use `await blurActiveElement(page);` when updating a textbox the second time before submitting a form to trigger validation. + +7. Timeout Configuration: + - Always use Playwright's built-in auto-waiting assertions: `toHaveURL()`, `toBeVisible()`, `toBeEnabled()`, `toHaveValue()`, `toContainText()`. + - Never add timeouts to `.click()`, `.waitForSelector()`, etc. + - Global timeout configuration is handled in the shared Playwright. Don't change this. + +8. Write deterministic tests - This is critical for reliable testing: + - Each test should have a clear, linear flow of actions and assertions. + - Never use if statements, custom error handling, or try/catch blocks in tests. + - Never use regular expressions in tests; use simple string matching instead. + +9. What to test: + - Enter invalid values, such as empty strings, only whitespace characters, long strings, negative numbers, Unicode, etc. + - Tooltips, keyboard navigation, accessibility, validation messages, translations, responsiveness, etc. + +10. Test Fixtures and Page Management: + - Use appropriate fixtures: `{ page }` for basic tests, `{ anonymousPage }` for tests with existing tenant/owner but not logged in, `{ ownerPage }`, `{ adminPage }`, `{ memberPage }` for authenticated tests. + - Destructure anonymous page data: `const { page, tenant } = anonymousPage; const existingUser = tenant.owner;` + - Pre-logged in users (`ownerPage`, `adminPage`, `memberPage`) are isolated between workers and will not conflict between tests. + - When using pre-logged in users, do not put the tenant or user into an invalid state that could affect other tests. + +11. Test Data and Constants: + - Use underscore separators: `const timeout = 30_000; // 30 seconds` + - Generate unique data: `const email = uniqueEmail();` + - Use faker.js to generate realistic test data: `const firstName = faker.person.firstName(); const email = faker.internet.email();` + - Long string testing: `const longEmail = \`${"a".repeat(90)}@example.com\`; // 101 characters total` + +12. Memory Management in E2E Tests: + - Playwright automatically handles browser context cleanup after tests + - Manual cleanup steps are unnecessary - focus on test clarity over micro-optimizations + - E2E test suites have minimal memory leak concerns due to their limited scope and duration + +## Examples + +### ✅ Good Step Naming Examples +```typescript +// ✅ DO: Business action + details & expected outcome +await step("Submit invalid email & verify validation error")(async () => { + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); + + await expectValidationError(context, "Invalid email."); +})(); + +await step("Sign up with valid credentials & verify account creation")(async () => { + await page.getByRole("button", { name: "Submit" }).click(); + + await expect(page.getByText("Welcome")).toBeVisible(); +})(); + +await step("Update user role to admin & verify permission change")(async () => { + const userRow = page.locator("tbody tr").first(); + + await userRow.getByLabel("User actions").click(); + await page.getByRole("menuitem", { name: "Change role" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Change user role" })).toBeVisible(); +})(); +``` + +### ❌ Bad Step Naming Examples +```typescript +// ❌ DON'T: Pure assertion steps without actions +await step("Verify button is visible")(async () => { + await expect(page.getByRole("button")).toBeVisible(); // No action, only assertion +})(); + +// ❌ DON'T: Using test/assertion prefixes +await step("Check user permissions")(async () => { // "Check" is assertion prefix + await expect(page.getByText("Admin")).toBeVisible(); +})(); + +await step("Validate form state")(async () => { // "Validate" is assertion prefix + await expect(page.getByRole("textbox")).toBeEmpty(); +})(); + +await step("Ensure user is deleted")(async () => { // "Ensure" is assertion prefix + await expect(page.getByText("user@example.com")).not.toBeVisible(); +})(); +``` + +### ✅ Complete Test Example +```typescript +import { step } from "@shared/e2e/utils/step-decorator"; +import { expectValidationError, blurActiveElement, createTestContext } from "@shared/e2e/utils/test-assertions"; +import { testUser } from "@shared/e2e/utils/test-data"; + +test.describe("@smoke", () => { + test("should complete signup with validation", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Submit invalid email & verify validation error")(async () => { + await page.goto("/signup"); + await page.getByLabel("Email").fill("invalid-email"); + await blurActiveElement(page); // ✅ DO: Trigger validation when updating textbox second time + + await expectValidationError(context, "Invalid email."); + })(); + + await step("Sign up with valid email & verify verification redirect")(async () => { + await page.getByLabel("Email").fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/verify"); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should handle user management with pre-logged owner", async ({ ownerPage }) => { + createTestContext(ownerPage); // ✅ DO: Create context for pre-logged users + + await step("Access user management & verify owner permissions")(async () => { + await ownerPage.getByRole("button", { name: "Users" }).click(); + + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30_000; // 30 seconds + const codeValidationTimeout = 60_000; // 5 minutes + const sessionTimeout = codeValidationTimeout + 60_000; // 6 minutes + + test("should handle user logout after to many login attempts", async ({ page }) => { // ✅ DO: use new page, when testing e.g. account lockout + test.setTimeout(sessionTimeout); // ✅ DO: Set timeout based on actual wait times + const context = createTestContext(page); + + // ... + + await step("Wait for code expiration & verify timeout behavior")(async () => { + await page.goto("/login/verify"); + await page.waitForTimeout(codeValidationTimeout); // ✅ DO: Use actual waits in @slow tests + + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + })(); + }); +}); +``` + +```typescript +test.describe("@security", () => { // ❌ DON'T: Don't invent new tags - use @smoke, @comprehensive, @slow only + test("should handle login", async ({ page }) => { + // ❌ DON'T: Skip createTestContext(page); step + page.setDefaultTimeout(5000); // ❌ DON'T: Set timeouts manually - use global config + + // ❌ DON'T: Use test/assertion prefixes in step descriptions + await step("Test login functionality")(async () => { // ❌ Should be "Submit login form & verify authentication" + await step("Verify button is visible")(async () => { // ❌ Should be "Navigate to page & verify button is visible" + await step("Check user permissions")(async () => { // ❌ Should be "Click user menu & verify permissions" + if (page.url().includes("/login/verify")) { // ❌ DON'T: Add conditional logic - tests should be linear + await page.waitForTimeout(2000); // ❌ DON'T: Add manual timeouts + // Continue with verification... // ❌ DON'T: Write verbose explanatory comments + } + + await page.click("#submit-btn"); // ❌ DON'T: Use CSS selectors - use semantic selectors + + // ❌ DON'T: Skip assertions for side effects + })(); + + // ❌ DON'T: Use regular expressions - use simple string matching instead + await expect(page.getByText(/welcome.*home/i)).toBeVisible(); // ❌ Should be: page.getByText("Welcome home") + await expect(page.locator('input[name*="email"]')).toBeFocused(); // ❌ Should be: page.getByLabel("Email") + }); + + // ❌ DON'T: Place assertions outside test functions + expect(page.url().includes("/admin") || page.url().includes("/login")).toBeTruthy(); // ❌ DON'T: Use ambiguous assertions + + // ❌ DON'T: Use try/catch to handle flaky behavior - makes tests unreliable + try { + await page.waitForLoadState("networkidle"); // ❌ DON'T: Add timeout logic in tests + await page.getByRole("button", { name: "Submit" }).click({ timeout: 1000 }); // ❌ DON'T: Add timeouts to actions + } catch (error) { + await page.waitForTimeout(1000); // ❌ DON'T: Add manual waits + console.log("Retrying..."); // ❌ DON'T: Add custom error handling + } +}); + +// ❌ DON'T: Create tests without proper organization +test("isolated test without describe block", async ({ page }) => { + // ❌ DON'T: Violates organization rules +}); +``` diff --git a/.windsurf/workflows/create-e2e-tests.md b/.windsurf/workflows/create-e2e-tests.md new file mode 100644 index 0000000000..90aca4bf06 --- /dev/null +++ b/.windsurf/workflows/create-e2e-tests.md @@ -0,0 +1,63 @@ +--- +description: Workflow for creating end-to-end tests +--- + +# E2E Testing Workflow + +This workflow guides you through the process of creating comprehensive end-to-end tests for specific features like login and signup. It focuses on identifying what tests to write, planning complex scenarios, and ensuring tests follow the established conventions. + +## Workflow + +1. Understand the feature under test: + - Study the frontend components and their interactions. + - Review API endpoints and authentication flows. + - Understand validation rules and error handling. + - Identify key user interactions and expected behaviors. + +2. Use Browser MCP to explore the webapp functionality: + - Navigate to the application: `mcp0_browser_navigate({ url: "https://localhost:9000" })`. + - Interact with the feature manually to understand user flows. + - Take snapshots to identify UI elements and their structure. + - Document key interactions and expected behaviors. + - Note any edge cases or potential issues discovered during exploration. + +3. Review existing test examples: + - Read [End-to-End Tests](/.windsurf/rules/end-to-end-tests/e2e-tests.md) for detailed information. + - Examine [signup.spec.ts](/application/account-management/WebApp/tests/e2e/signup.spec.ts) and [login.spec.ts](/application/account-management/WebApp/tests/e2e/login.spec.ts) for inspiration. + - Note the structure, assertions, test organization, and the "Act & Assert:" comment format. + +4. Plan comprehensive test scenarios: + - Identify standard user journeys through the feature. + - Plan for complex multi-session scenarios like: + - Concurrent sessions: What happens when a user has two tabs open? + - Cross-session state changes: What happens when state changes in one session affect another? + - Authentication conflicts: How does the system handle authentication changes across sessions? + - Form submissions across sessions: What happens with concurrent form submissions? + - Antiforgery token handling: How are antiforgery tokens managed across tabs? + - Browser navigation: Back/forward buttons, refresh, direct URL access. + - Network conditions: Slow connections, disconnections during operations. + - Input validation: Boundary values, special characters, extremely long inputs. + - Accessibility: Keyboard navigation, screen reader compatibility. + - Localization: Testing with different languages and formats. + +5. Categorize tests appropriately: + - `@smoke`: Essential functionality that will run on deployment of any system. + - Create one comprehensive smoke.spec.ts per self-contained system. + - Test complete user journeys: signup → profile setup → invite users → manage roles → tenant settings → logout. + - Include validation errors, retries, and recovery scenarios within the journey. + - `@comprehensive`: More thorough tests covering edge cases that will run on deployment of the system under test. + - Focus on specific feature areas with deep testing of edge cases. + - Group related scenarios to minimize test count while maximizing coverage. + - `@slow`: Tests involving timeouts or waiting periods that will run ad-hoc, when features under test are changed. + +6. Create or update test structure: + - For smoke tests: Create/update `application/[scs-name]/WebApp/tests/e2e/smoke.spec.ts`. + - For comprehensive tests: Create feature-specific files like `user-management.spec.ts`, `authentication.spec.ts`. + - Avoid creating many small, isolated tests - prefer comprehensive scenarios that test multiple aspects. + +## Key principles + +- Comprehensive coverage: Test all critical paths and important edge cases. +- Follow conventions: Adhere to the established patterns in [End-to-End Tests](/.windsurf/rules/end-to-end-tests/e2e-tests.md). +- Clear organization: Properly categorize tests and use descriptive names. +- Realistic user journeys: Test scenarios that reflect actual user behavior. diff --git a/.windsurf/workflows/implement-product-increment.md b/.windsurf/workflows/implement-product-increment.md index eb36381f4f..8b477e4236 100644 --- a/.windsurf/workflows/implement-product-increment.md +++ b/.windsurf/workflows/implement-product-increment.md @@ -16,10 +16,14 @@ Follow these steps which describe in detail how you must implement the tasks in Before implementing each task, review the relevant rules thoroughly: - - For **backend tasks**: - - Review all the [Backend](/.windsurf/rules/backend) rule files. - - For **frontend tasks**: - - Review all the [Frontend](/.windsurf/rules/frontend) rule files. +- For **backend tasks**: + - Review all the [Backend](/.windsurf/rules/backend) rule files. +- For **frontend tasks**: + - Review all the [Frontend](/.windsurf/rules/frontend) rule files. +- For **end-to-end tests**: + - Review the [E2E Testing Workflow](/.windsurf/workflows/create-e2e-tests.md) and all the [End-to-End Tests](/.windsurf/rules/end-to-end-tests) rule files. +- For **Developer CLI commands**: + - Review all the [Developer CLI](/.windsurf/rules/developer-cli) rule files. These rules define the conventions and must be strictly adhered to during implementation. diff --git a/application/account-management/WebApp/rsbuild.config.ts b/application/account-management/WebApp/rsbuild.config.ts index df0771fb62..e7dff6de78 100644 --- a/application/account-management/WebApp/rsbuild.config.ts +++ b/application/account-management/WebApp/rsbuild.config.ts @@ -11,6 +11,14 @@ import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; const customBuildEnv: CustomBuildEnv = {}; export default defineConfig({ + tools: { + rspack: { + // Exclude tests/e2e directory from file watching to prevent hot reloading issues + watchOptions: { + ignored: ["**/tests/**", "**/playwright-report/**"] + } + } + }, plugins: [ pluginReact(), pluginTypeCheck(), diff --git a/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts b/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts new file mode 100644 index 0000000000..6bc96dd5df --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/localization-flows.spec.ts @@ -0,0 +1,188 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +test.describe("@comprehensive", () => { + test("should handle language changes across signup, authentication, and logout flows", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Navigate to signup page & verify default English interface")(async () => { + await page.goto("/signup"); + + // Verify default English interface + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + })(); + + await step("Click language button and select Danish & verify interface updates")(async () => { + await page.getByRole("button", { name: "Change language" }).click(); + await page.getByRole("menuitem", { name: "Dansk" }).click(); + + // Verify interface updates to Danish and preference is saved + await expect(page.getByRole("heading", { name: "Opret din konto" })).toBeVisible(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Complete signup with Danish interface & verify language persists through flow")(async () => { + await page.getByRole("textbox", { name: "E-mail" }).fill(user.email); + await page.getByRole("button", { name: "Opret din konto" }).click(); + + await expect(page).toHaveURL("/signup/verify"); + await expect(page.getByRole("heading", { name: "Indtast din bekræftelseskode" })).toBeVisible(); + })(); + + await step("Complete verification with Danish interface & verify navigation to admin")(async () => { + // Auto-submits on 6 characters + await page.keyboard.type(getVerificationCode()); + + await expect(page).toHaveURL("/admin"); + })(); + + await step("Complete profile setup in Danish & verify profile form works")(async () => { + // Fill profile form in Danish + await expect(page.getByRole("dialog", { name: "Brugerprofil" })).toBeVisible(); + await page.getByRole("textbox", { name: "Fornavn" }).fill(user.firstName); + await page.getByRole("textbox", { name: "Efternavn" }).fill(user.lastName); + await page.getByRole("textbox", { name: "Titel" }).fill("CEO"); + await page.getByRole("button", { name: "Gem ændringer" }).click(); + + // Verify Danish success message and navigation + await expectToastMessage(context, "Profil opdateret succesfuldt"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + })(); + + await step("Click logout from Danish interface & verify language persists after logout")(async () => { + await page.getByRole("button", { name: "Brugerprofilmenu" }).click(); + await page.getByRole("menuitem", { name: "Log ud" }).click(); + + // Verify Danish language persists after logout + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + await expect(page.getByRole("heading", { name: "Hej! Velkommen tilbage" })).toBeVisible(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Change login page language to English & verify interface updates")(async () => { + await page.getByRole("button", { name: "Skift sprog" }).click(); + await page.getByRole("menuitem", { name: "English" }).click(); + + // Verify interface updates to English + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + })(); + + await step("Login with English interface & verify language resets to saved preference")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); + await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); + })(); + + await step("Complete login verification & verify language resets to user's saved preference")(async () => { + // Auto-submits on 6 characters + await page.keyboard.type(getVerificationCode()); + + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Click language button and reset to English & verify language change works")(async () => { + await page.getByRole("button", { name: "Skift sprog" }).click(); + await page.getByRole("menuitem", { name: "English" }).click(); + + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + + // Fix bug where localStorage is not updated before page reload + await page.reload(); + + await expect(page.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + })(); + }); + + test("should handle language persistence across different user sessions", async ({ browser }) => { + const page1 = await (await browser.newContext()).newPage(); + const page2 = await (await browser.newContext()).newPage(); + + const testContext1 = createTestContext(page1); + const testContext2 = createTestContext(page2); + const user1 = testUser(); + const user2 = testUser(); + + await step("Complete signup for first user with Danish & verify preference saved")(async () => { + // Set up Danish interface + await page1.goto("/signup"); + await page1.getByRole("button", { name: "Change language" }).click(); + await page1.getByRole("menuitem", { name: "Dansk" }).click(); + await expect(page1.getByRole("heading", { name: "Opret din konto" })).toBeVisible(); + + // Complete signup flow + await page1.getByRole("textbox", { name: "E-mail" }).fill(user1.email); + await page1.getByRole("button", { name: "Opret din konto" }).click(); + await expect(page1).toHaveURL("/signup/verify"); + + // Auto-submits on 6 characters + await page1.keyboard.type(getVerificationCode()); + + // Complete profile in Danish + await page1.getByRole("textbox", { name: "Fornavn" }).fill(user1.firstName); + await page1.getByRole("textbox", { name: "Efternavn" }).fill(user1.lastName); + await page1.getByRole("button", { name: "Gem ændringer" }).click(); + + // Verify Danish preference saved + await expectToastMessage(testContext1, 200, "Profil opdateret succesfuldt"); + await expect(page1.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + await expect(page1.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Complete signup for second user with default English & verify different language preference")( + async () => { + await completeSignupFlow(page2, expect, user2, testContext2, true); + + await expect(page2.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(page2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + } + )(); + + await step("Login first user in new browser context & verify language preference persists")(async () => { + const newContext1 = await browser.newContext(); + const newPage1 = await newContext1.newPage(); + + // Login with English interface + await newPage1.goto("/login"); + await newPage1.getByRole("textbox", { name: "Email" }).fill(user1.email); + await newPage1.getByRole("button", { name: "Continue" }).click(); + await expect(newPage1).toHaveURL("/login/verify"); + + // Auto-submits on 6 characters + await newPage1.keyboard.type(getVerificationCode()); + + // Verify Danish preference is restored after login + await expect(newPage1).toHaveURL("/admin"); + await expect(newPage1.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + await expect(newPage1.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("da-DK"); + })(); + + await step("Login second user in new browser context & verify language preference persists")(async () => { + const newContext2 = await browser.newContext(); + const newPage2 = await newContext2.newPage(); + + // Login with English interface + await newPage2.goto("/login"); + await newPage2.getByRole("textbox", { name: "Email" }).fill(user2.email); + await newPage2.getByRole("button", { name: "Continue" }).click(); + await expect(newPage2).toHaveURL("/login/verify"); + + // Auto-submits on 6 characters + await newPage2.keyboard.type(getVerificationCode()); + + // Verify English preference is maintained + await expect(newPage2).toHaveURL("/admin"); + await expect(newPage2.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(newPage2.evaluate(() => localStorage.getItem("preferred-locale"))).resolves.toBe("en-US"); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/login-flows.spec.ts b/application/account-management/WebApp/tests/e2e/login-flows.spec.ts new file mode 100644 index 0000000000..2fe892b543 --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/login-flows.spec.ts @@ -0,0 +1,255 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { + blurActiveElement, + createTestContext, + expectNetworkErrors, + expectToastMessage +} from "@shared/e2e/utils/test-assertions"; +import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +test.describe("@smoke", () => { + test("should handle login flow with validation, security, authentication protection, and logout", async ({ + anonymousPage + }) => { + const { page, tenant } = anonymousPage; + const existingUser = tenant.owner; + const context = createTestContext(page); + + // === EMAIL VALIDATION EDGE CASES === + // Email validation is comprehensively tested in signup-flows.spec.ts + await step("Navigate to login page & verify heading displays")(async () => { + await page.goto("/login"); + + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + })(); + + // === SUCCESSFUL LOGIN FLOW === + await step("Enter valid email & verify navigation to verification page")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); + await blurActiveElement(page); + await page.getByRole("button", { name: "Continue" }).click(); + + // Verify verification page state + await expect(page).toHaveURL("/login/verify"); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + await expect(page.getByRole("button", { name: "Verify" })).toBeDisabled(); + + // Verify help text is visible but resend button is not yet available + await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); + await expect(page.getByText("Request a new code")).not.toBeVisible(); + })(); + + await step("Enter wrong verification code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG1"); // The verification code auto submits the first time + + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Complete successful login & verify navigation to admin")(async () => { + // Re-enter correct code (manual submit required after failed attempt) + await page.locator('input[autocomplete="one-time-code"]').first().focus(); + await page.keyboard.type(getVerificationCode()); // The verification does not auto submit the second time + await page.getByRole("button", { name: "Verify" }).click(); + + // Verify successful login + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + // === AUTHENTICATION PROTECTION === + await step("Click logout from user menu & verify redirect to login")(async () => { + // Mark 401 as expected during logout transition (React Query may have in-flight requests) + context.monitoring.expectedStatusCodes.push(401); + + await page.getByRole("button", { name: "User profile menu" }).click(); + await page.getByRole("menuitem", { name: "Log out" }).click(); + + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + })(); + + await step("Access protected routes while unauthenticated & verify redirect to login")(async () => { + // Try accessing users page + await page.goto("/admin/users"); + await expect(page).toHaveURL("/login?returnPath=%2Fadmin%2Fusers"); + await expectNetworkErrors(context, [401]); + + // Try accessing admin dashboard + await page.goto("/admin"); + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + await expectNetworkErrors(context, [401]); + })(); + + // === SECURITY EDGE CASES === + await step("Navigate with malicious redirect URL & verify prevention")(async () => { + await page.goto("/login?returnPath=http://hacker.com"); + + // Verify malicious returnPath is stripped + await expect(page).toHaveURL("/login"); + })(); + + await step("Complete login after security check & verify authentication works")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify"); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + + await page.keyboard.type(getVerificationCode()); // The verification code auto submits + + await expect(page).toHaveURL("/admin"); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should enforce rate limiting for failed login attempts", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Create test user")(async () => { + await completeSignupFlow(page, expect, user, context, false); + })(); + + await step("Navigate to login and submit email & verify navigation to verification page")(async () => { + await page.goto("/login"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + // Verify initial verification page state + await expect(page).toHaveURL("/login/verify"); + await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); + await expect(page.getByText("Request a new code")).not.toBeVisible(); + })(); + + await step("Enter first wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG1"); // The verification code auto submits the first time + + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter second wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG2"); + await page.getByRole("button", { name: "Verify" }).click(); + + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter third wrong code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG3"); + await page.getByRole("button", { name: "Verify" }).click(); + + await expectToastMessage(context, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Enter fourth wrong code & verify rate limiting triggers")(async () => { + await page.keyboard.type("WRONG4"); + await page.getByRole("button", { name: "Verify" }).click(); + + // Verify rate limiting is enforced + await expect(page.getByText("Too many attempts, please request a new code.").first()).toBeVisible(); + await expectToastMessage(context, 403, "Too many attempts, please request a new code."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeDisabled(); + await expect(page.getByRole("button", { name: "Verify" })).toBeDisabled(); + })(); + }); +}); + +test.describe("@comprehensive", () => { + test("should show detailed error message when too many login attempts are made", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + await step("Create test user")(async () => { + await completeSignupFlow(page, expect, user, context, false); + })(); + + await step("Make 3 login attempts & verify each navigates to verify page")(async () => { + // Make 3 login attempts within rate limit threshold + for (let attempt = 1; attempt <= 3; attempt++) { + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify"); + } + })(); + + await step("Make 4th login attempt & verify rate limiting triggers")(async () => { + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + // Verify rate limiting prevents navigation + await expect(page).toHaveURL("/login"); + await expectToastMessage( + context, + 429, + "Too many attempts to confirm this email address. Please try again later." + ); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30000; // 30 seconds + const codeValidationTimeout = 300000; // 5 minutes (300 seconds) + const sessionTimeout = codeValidationTimeout + 60000; // 6 minutes total + + test("should allow resend code 30 seconds after login but then not after code has expired", async ({ page }) => { + test.setTimeout(sessionTimeout); + const context = createTestContext(page); + const user = testUser(); + + await step("Create test user and navigate to verify page")(async () => { + // Create user and navigate to login verification + await completeSignupFlow(page, expect, user, context, false); + await page.goto("/login"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Continue" }).click(); + + // Verify initial state + await expect(page).toHaveURL("/login/verify"); + await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); + })(); + + await step("Wait 30 seconds & verify request code button appears")(async () => { + await page.waitForTimeout(requestNewCodeTimeout); + + // Verify UI changes after timeout + await expect( + page.getByRole("textbox", { name: "Can't find your code? Check your spam folder." }) + ).not.toBeVisible(); + await expect(page.getByText("Request a new code")).toBeVisible(); + })(); + + await step("Click request new code & verify success message and button hides")(async () => { + await page.getByRole("button", { name: "Request a new code" }).click(); + + await expectToastMessage(context, "A new verification code has been sent to your email."); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + + await step("Wait for code expiration & verify expiration message displays")(async () => { + await page.waitForTimeout(codeValidationTimeout); + + // Verify expiration state + await expect(page).toHaveURL("/login/verify"); + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + }); + + // 5-minute request new code test is kept in the 30-second test above which also tests the 5-minute scenario +}); diff --git a/application/account-management/WebApp/tests/e2e/mobile-view-flows.spec.ts b/application/account-management/WebApp/tests/e2e/mobile-view-flows.spec.ts new file mode 100644 index 0000000000..1c653b33d7 --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/mobile-view-flows.spec.ts @@ -0,0 +1,613 @@ +import { faker } from "@faker-js/faker"; +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { completeSignupFlow, testUser } from "@shared/e2e/utils/test-data"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +test.describe("@comprehensive", () => { + /** + * Tests mobile-specific functionality including navigation, user profile editing, + * language switching, theme switching, and keyboard navigation on the users table. + * Covers: + * - Mobile menu navigation with hidden top menu and side menu + * - User profile editing through mobile menu + * - Language switching functionality + * - Theme switching functionality + * - Keyboard navigation on users table without auto-opening side pane + * - Navigation between multiple users and manual side pane opening + */ + test("should handle mobile navigation and user management with keyboard accessibility", async ({ page }) => { + const context = createTestContext(page); + const owner = testUser(); + + // Set mobile viewport + await page.setViewportSize({ width: 390, height: 844 }); + + await step("Complete owner signup")(async () => { + await completeSignupFlow(page, expect, owner, context); + })(); + + await step("Navigate to admin dashboard & verify mobile layout")(async () => { + await page.goto("/admin"); + + // Wait for page to load - heading contains the user's name + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + })(); + + await step("Verify mobile layout with hidden menus & English UI")(async () => { + // Wait for page to load - heading contains the user's name + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + + // Wait for mobile layout to be ready + await expect(page.getByRole("button", { name: "Open navigation menu" })).toBeVisible(); + + // Verify side menu navigation links are hidden + await expect(page.getByLabel("Main navigation").getByRole("link", { name: "Home" })).not.toBeVisible(); + await expect(page.getByLabel("Main navigation").getByRole("link", { name: "Account" })).not.toBeVisible(); + await expect(page.getByLabel("Main navigation").getByRole("link", { name: "Users" })).not.toBeVisible(); + + // Verify top menu buttons are hidden on mobile + await expect(page.getByRole("button", { name: "Change theme" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Contact support" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Change language" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "User profile menu" })).not.toBeVisible(); + })(); + + await step("Open mobile menu & verify all navigation and settings are accessible")(async () => { + await page.getByRole("button", { name: "Open navigation menu" }).click(); + + const mobileDialog = page.getByRole("dialog"); + await expect(mobileDialog).toBeVisible(); + + // Verify user profile section is visible + await expect(mobileDialog.getByRole("button", { name: "Edit" })).toBeVisible(); + + // Verify all menu options are present + await expect(mobileDialog.getByRole("button", { name: "Log out" })).toBeVisible(); + await expect(mobileDialog.getByRole("button", { name: "Theme" })).toBeVisible(); + await expect(mobileDialog.getByRole("button", { name: "Language" })).toBeVisible(); + await expect(mobileDialog.getByRole("button", { name: "Contact support" })).toBeVisible(); + + // Verify navigation links + await expect(mobileDialog.getByRole("link", { name: "Home" })).toBeVisible(); + await expect(mobileDialog.getByRole("link", { name: "Account" })).toBeVisible(); + await expect(mobileDialog.getByRole("link", { name: "Users" })).toBeVisible(); + })(); + + // === USER PROFILE EDITING === + await step("Edit user profile through mobile menu & verify profile modal opens")(async () => { + const mobileDialog = page.getByRole("dialog", { name: "Mobile navigation menu" }); + await mobileDialog.getByRole("button", { name: "Edit" }).click(); + + // Wait for mobile menu to close and profile modal to open + await expect(mobileDialog).not.toBeVisible(); + + // Profile modal should open - wait for it to be visible + const profileModal = page.getByRole("dialog", { name: "User profile" }); + await expect(profileModal).toBeVisible(); + + // Verify form fields are present + await expect(profileModal.getByLabel("First name")).toBeVisible(); + await expect(profileModal.getByLabel("Last name")).toBeVisible(); + await expect(profileModal.getByLabel("Email")).toBeVisible(); + await expect(profileModal.getByLabel("Title")).toBeVisible(); + })(); + + await step("Update profile information & verify changes are saved")(async () => { + const profileModal = page.getByRole("dialog", { name: "User profile" }); + const newTitle = faker.person.jobTitle(); + + // Fill in the title field + await profileModal.getByLabel("Title").fill(newTitle); + // Click save button + await profileModal.getByRole("button", { name: "Save" }).click(); + + // Wait for success toast + await expectToastMessage(context, "Profile updated successfully"); + await expect(profileModal).not.toBeVisible(); + + // Verify changes are reflected in mobile menu + await page.getByRole("button", { name: "Open navigation menu" }).click(); + await expect(page.getByText(newTitle)).toBeVisible(); + + // Close mobile menu by clicking the X button + const mobileDialog = page.getByRole("dialog"); + await mobileDialog.getByRole("button", { name: "Close menu" }).click(); + await expect(mobileDialog).not.toBeVisible(); + })(); + + // === LANGUAGE SWITCHING === + await step("Change language to Danish through mobile menu & verify UI updates")(async () => { + await page.getByRole("button", { name: "Open navigation menu" }).click(); + + const mobileDialog = page.getByRole("dialog", { name: "Mobile navigation menu" }); + await mobileDialog.getByRole("button", { name: "Language" }).click(); + + // Wait for language menu to open + await expect(page.getByRole("menu")).toBeVisible(); + + // Select Danish + await page.getByRole("menuitem", { name: "Dansk" }).click(); + + // Mobile menu should close + await expect(mobileDialog).not.toBeVisible(); + + // Verify language changed - check heading + await expect(page.getByRole("heading", { name: "Velkommen hjem" })).toBeVisible(); + })(); + + await step("Change language back to English & verify language updates")(async () => { + await page.getByRole("button", { name: "Åbn navigationsmenu" }).click(); + + const mobileDialog = page.getByRole("dialog"); + await mobileDialog.getByRole("button", { name: "Sprog" }).click(); + + // Wait for language menu to open + await expect(page.getByRole("menu")).toBeVisible(); + + // Select English + await page.getByRole("menuitem", { name: "English" }).click(); + + // Mobile menu should close + await expect(mobileDialog).not.toBeVisible(); + + // Verify language changed back - check heading + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + // === THEME SWITCHING === + await step("Change theme through mobile menu & verify theme applies")(async () => { + await page.getByRole("button", { name: "Open navigation menu" }).click(); + + const mobileDialog = page.getByRole("dialog"); + await mobileDialog.getByRole("button", { name: "Theme" }).click(); + + // Wait for theme menu to open + await expect(page.getByRole("menu")).toBeVisible(); + + // Select dark theme + await page.getByRole("menuitem", { name: "Dark" }).click(); + + // Mobile menu should close + await expect(mobileDialog).not.toBeVisible(); + + // Verify dark theme is applied + await expect(page.locator("html")).toHaveClass("dark"); + })(); + + // === NAVIGATION TO USERS PAGE === + await step("Navigate to users page through mobile menu & verify navigation works")(async () => { + await page.getByRole("button", { name: "Open navigation menu" }).click(); + + const mobileDialog = page.getByRole("dialog"); + await mobileDialog.getByRole("link", { name: "Users" }).click(); + + // Mobile menu should close + await expect(mobileDialog).not.toBeVisible(); + + // Verify navigation to users page + await expect(page).toHaveURL("/admin/users"); + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + + // === KEYBOARD NAVIGATION TESTS === + await step("Invite 3 test users & verify they appear in the table")(async () => { + // Create test users + const inviteUserButton = page.getByRole("button", { name: "Invite user" }); + + // Create 3 additional users + for (let i = 0; i < 3; i++) { + const user = testUser(); + + await inviteUserButton.click(); + const dialog = page.getByRole("dialog", { name: "Invite user" }); + await expect(dialog).toBeVisible(); + + await dialog.getByLabel("Email").fill(user.email); + await dialog.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(dialog).not.toBeVisible(); + } + + // Verify we have at least 4 users in the table (1 owner + 3 new users) + const rows = page.locator("tbody tr"); + const rowCount = await rows.count(); + expect(rowCount).toBeGreaterThanOrEqual(4); + })(); + + await step("Click first user & verify side pane opens on mobile")(async () => { + // Click the first row in the users table + const firstRow = page.locator("tbody tr").first(); + await firstRow.click(); + + // Ensure row is selected + await expect(firstRow).toHaveAttribute("aria-selected", "true"); + + // Verify side pane opens automatically on mobile when clicking a row + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + await expect(sidePane).toBeVisible(); + + // Wait for side pane to be fully interactive and close button to be visible + const closeButton = sidePane.locator("svg[aria-label='Close user profile']"); + await expect(closeButton).toBeVisible(); + + // Focus on the side pane to ensure Escape key is handled + await sidePane.focus(); + + // Close the side pane with Escape key + await page.keyboard.press("Escape"); + await expect(sidePane).not.toBeVisible(); + })(); + + await step("Navigate to second user with keyboard & verify side pane stays closed")(async () => { + // Press down arrow to move to second user + await page.keyboard.press("ArrowDown"); + + const secondRow = page.locator("tbody tr").nth(1); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + + // Verify side pane remains closed when using keyboard navigation + await expect(page.locator('[aria-label="User profile"]')).not.toBeVisible(); + })(); + + await step("Navigate to third user & manually open side pane with Enter key")(async () => { + // Press down arrow to move to third user + await page.keyboard.press("ArrowDown"); + + const thirdRow = page.locator("tbody tr").nth(2); + await expect(thirdRow).toHaveAttribute("aria-selected", "true"); + + // Press Enter to open the side pane + await page.keyboard.press("Enter"); + + // Verify side pane opens + const sidePane = page.locator('[aria-label="User profile"]'); + await expect(sidePane).toBeVisible(); + + // Wait for side pane animation to complete and close button to be visible + const closeButton = sidePane.locator("svg[aria-label='Close user profile']"); + await expect(closeButton).toBeVisible(); + })(); + + await step("Close side pane with Escape & verify it closes")(async () => { + const sidePane = page.locator('[aria-label="User profile"]'); + + // Ensure side pane is fully open + await expect(sidePane).toBeVisible(); + const closeButton = sidePane.locator("svg[aria-label='Close user profile']"); + await expect(closeButton).toBeVisible(); + + // Focus on the side pane to ensure Escape key is handled + await sidePane.focus(); + + // Press Escape to close the side pane + await page.keyboard.press("Escape"); + await expect(sidePane).not.toBeVisible(); + })(); + + await step("Click first user row & verify side pane opens automatically")(async () => { + // Re-select first user since selection was cleared + const firstRow = page.locator("tbody tr").first(); + await firstRow.click(); + await expect(firstRow).toHaveAttribute("aria-selected", "true"); + + // Verify side pane opened automatically + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + await expect(sidePane).toBeVisible(); + })(); + + await step("Press Escape key & verify side pane closes")(async () => { + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + + // Wait for side pane to be fully visible + await expect(sidePane).toBeVisible(); + + // Wait for close button to ensure side pane is fully rendered + const closeButton = sidePane.locator("svg[aria-label='Close user profile']"); + await expect(closeButton).toBeVisible(); + + // Focus on the side pane before pressing Escape + await sidePane.focus(); + + // Press Escape to close the side pane + await page.keyboard.press("Escape"); + + // Verify side pane closes + await expect(sidePane).not.toBeVisible(); + })(); + + await step("Click second user row & verify side pane opens automatically")(async () => { + // Navigate to second user and open side pane + const secondRow = page.locator("tbody tr").nth(1); + await secondRow.click(); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + + // Side pane should open automatically on click in mobile + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + await expect(sidePane).toBeVisible(); + })(); + + await step("Test closing side pane with X button & verify it works")(async () => { + // Click close button to close side pane + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + const closeButton = sidePane.locator("svg[aria-label='Close user profile']"); + await closeButton.click(); + + await expect(sidePane).not.toBeVisible(); + })(); + + await step("Verify mobile menu still works after side pane interactions")(async () => { + // Verify mobile menu still works after side pane interaction + await page.getByRole("button", { name: "Open navigation menu" }).click(); + await expect(page.getByRole("dialog", { name: "Mobile navigation menu" })).toBeVisible(); + + // Close mobile menu using escape key + await page.keyboard.press("Escape"); + })(); + }); + + /** + * Tests mobile-specific form interactions and validation. + * Covers: + * - Form submission through mobile menu + * - Touch interactions with form elements + * - Validation error display on mobile + * - Modal behavior on mobile viewport + */ + test("should handle mobile form interactions and validation correctly", async ({ ownerPage }) => { + createTestContext(ownerPage); + + // Set mobile viewport + await ownerPage.setViewportSize({ width: 375, height: 667 }); + + await step("Navigate to users page & open invite user dialog")(async () => { + await ownerPage.goto("/admin/users"); + // Check for either English or Danish heading + await expect(ownerPage.getByRole("heading", { level: 1 })).toBeVisible(); + + await ownerPage.getByRole("button", { name: "Invite user" }).click(); + + const dialog = ownerPage.getByRole("dialog", { name: "Invite user" }); + await expect(dialog).toBeVisible(); + + // Email validation is comprehensively tested in signup-flows.spec.ts + // Just cancel the dialog + })(); + + await step("Cancel dialog & verify mobile menu remains functional")(async () => { + const dialog = ownerPage.getByRole("dialog", { name: "Invite user" }); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + await expect(dialog).not.toBeVisible(); + + // Test mobile menu + await ownerPage.getByRole("button", { name: "Open navigation menu" }).click(); + await expect(ownerPage.getByRole("dialog", { name: "Mobile navigation menu" })).toBeVisible(); + + await ownerPage.keyboard.press("Escape"); + })(); + }); + + /** + * Tests mobile user selection behavior with mixed keyboard and mouse interactions. + * Ensures that: + * - Single click always single-selects a user (no accidental multi-select) + * - Keyboard navigation maintains selection when closing side pane + * - Mixed keyboard/mouse workflows work correctly + * - Multi-select only happens with Ctrl/Cmd modifier keys + */ + test("should handle mobile user selection with mixed keyboard and mouse interactions", async ({ page }) => { + const context = createTestContext(page); + const user = testUser(); + + // Set mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + + await step("Create a fresh tenant")(async () => { + await completeSignupFlow(page, expect, user, context, true); + })(); + + // === SETUP === + await step("Navigate to users page & invite 3 test users")(async () => { + await page.goto("/admin/users"); + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + + // Create 3 test users for selection testing + for (let i = 0; i < 3; i++) { + const user = testUser(); + + await page.getByRole("button", { name: "Invite user" }).click(); + const dialog = page.getByRole("dialog", { name: "Invite user" }); + await expect(dialog).toBeVisible(); + + await dialog.getByLabel("Email").fill(user.email); + await dialog.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(dialog).not.toBeVisible(); + } + + // Verify we have exactly 4 users (1 owner + 3 new users) in a fresh tenant + const rows = page.locator("tbody").first().locator("tr"); + const rowCount = await rows.count(); + expect(rowCount).toBe(4); + })(); + + // === SCENARIO 1: Test working functionality first === + await step("Click first user with mouse & verify single selection and side pane opens")(async () => { + const firstRow = page.locator("tbody tr").first(); + await expect(firstRow).toBeVisible(); + await firstRow.click(); + + // Verify only first row is selected + await expect(firstRow).toHaveAttribute("aria-selected", "true"); + const secondRow = page.locator("tbody tr").nth(1); + await expect(secondRow).toBeVisible(); + await expect(secondRow).toHaveAttribute("aria-selected", "false"); + + // Verify side pane opens + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + await expect(sidePane).toBeVisible(); + })(); + + await step("Close side pane using close button & verify it closes")(async () => { + // Click close button to close side pane + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + const closeButton = sidePane.locator("svg[aria-label='Close user profile']"); + await closeButton.click(); + + // Verify side pane closes + await expect(sidePane).not.toBeVisible(); + })(); + + await step("Navigate with keyboard & verify side pane stays closed")(async () => { + // Re-select the first row since selection was cleared + const firstRow = page.locator("tbody tr").first(); + await firstRow.click(); + + // Close the side pane that opens + await page.keyboard.press("Escape"); + + // Now click first row again and verify selection + await firstRow.click(); + await expect(firstRow).toHaveAttribute("aria-selected", "true"); + + // Close side pane + await page.keyboard.press("Escape"); + + // Use keyboard to navigate to second row + await page.keyboard.press("ArrowDown"); + + // Verify selection moved to second row + const secondRow = page.locator("tbody tr").nth(1); + await expect(firstRow).toHaveAttribute("aria-selected", "false"); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + + // Verify side pane stays closed during keyboard navigation + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + await expect(sidePane).not.toBeVisible(); + })(); + + await step("Press Enter to open side pane for second user & verify it opens")(async () => { + await page.keyboard.press("Enter"); + + // Verify side pane opens + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + await expect(sidePane).toBeVisible(); + })(); + + await step("Click X button to close side pane & verify selection maintained")(async () => { + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + const closeButton = sidePane.locator("svg[aria-label='Close user profile']"); + await closeButton.click(); + + // Verify side pane closes + await expect(sidePane).not.toBeVisible(); + })(); + + // === PREVIOUSLY FAILING SCENARIOS - should now work with single selection mode === + await step("Simple click on second user after first is selected & verify single selection")(async () => { + // Close side pane + await page.keyboard.press("Escape"); + + const firstRow = page.locator("tbody tr").first(); + const secondRow = page.locator("tbody tr").nth(1); + + // Click first user + await firstRow.click(); + await expect(firstRow).toHaveAttribute("aria-selected", "true"); + + // Close side pane + await page.keyboard.press("Escape"); + await expect(page.locator("aside").filter({ hasText: "User profile" })).not.toBeVisible(); + + // Click second user - should single select with our fix + await secondRow.click(); + + // With single selection mode, only second user should be selected + await expect(firstRow).toHaveAttribute("aria-selected", "false"); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + })(); + + await step("Click third user after keyboard navigation and side pane interaction & verify single selection")( + async () => { + // Reset state - ensure any side pane is closed first + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + await expect(sidePane).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(sidePane).not.toBeVisible(); + + // Click first user + const firstRow = page.locator("tbody tr").first(); + await firstRow.click(); + await page.keyboard.press("Escape"); + + // Re-select first row since selection was cleared + await firstRow.click(); + await expect(firstRow).toHaveAttribute("aria-selected", "true"); + await page.keyboard.press("Escape"); + + // Navigate to second with keyboard + const secondRow = page.locator("tbody tr").nth(1); + await page.keyboard.press("ArrowDown"); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + + // Open side pane with Enter + const thirdRow = page.locator("tbody tr").nth(2); + await page.keyboard.press("Enter"); + await expect(page.locator("aside").filter({ hasText: "User profile" })).toBeVisible(); + + // Close with X button + const closeButton = page + .locator("aside") + .filter({ hasText: "User profile" }) + .locator("svg[aria-label='Close user profile']"); + await closeButton.click(); + await expect(page.locator("aside").filter({ hasText: "User profile" })).not.toBeVisible(); + + // Click third user - should single select with our fix + await thirdRow.click(); + + // With single selection mode, only third user should be selected + await expect(firstRow).toHaveAttribute("aria-selected", "false"); + await expect(secondRow).toHaveAttribute("aria-selected", "false"); + await expect(thirdRow).toHaveAttribute("aria-selected", "true"); + } + )(); + + await step("Rapid clicking between users & verify single selection")(async () => { + // Reset state - ensure any side pane is closed first + const sidePane = page.locator("aside").filter({ hasText: "User profile" }); + await expect(sidePane).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(sidePane).not.toBeVisible(); + + // Rapid clicks - on mobile, side pane opens after each click + const firstRow = page.locator("tbody tr").first(); + await firstRow.click(); + + // Close side pane that opened + await expect(page.locator("aside").filter({ hasText: "User profile" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator("aside").filter({ hasText: "User profile" })).not.toBeVisible(); + + const secondRow = page.locator("tbody tr").nth(1); + await secondRow.click(); + + // Close side pane again + await expect(page.locator("aside").filter({ hasText: "User profile" })).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.locator("aside").filter({ hasText: "User profile" })).not.toBeVisible(); + + const thirdRow = page.locator("tbody tr").nth(2); + await thirdRow.click(); + + // With single selection mode, only third user should be selected + await expect(firstRow).toHaveAttribute("aria-selected", "false"); + await expect(secondRow).toHaveAttribute("aria-selected", "false"); + await expect(thirdRow).toHaveAttribute("aria-selected", "true"); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts b/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts new file mode 100644 index 0000000000..4090eeccac --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/permission-based-ui-flows.spec.ts @@ -0,0 +1,350 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +test.describe("@smoke", () => { + /** + * PERMISSION-BASED UI ACCESS CONTROL TESTS + * + * Tests the permission-based UI behavior ensuring UI elements accurately reflect + * what actions users can perform based on backend authorization rules by creating + * users with different roles and testing the UI behavior in the same session. + * + * Note: Current test fixtures infrastructure creates only Owner users, so we test + * by creating users with different roles and switching between them in a single session. + */ + test("should enforce permission-based UI visibility and self-action restrictions", async ({ page }) => { + const context = createTestContext(page); + const owner = testUser(); + const member = testUser(); + + // Create owner and member users + await step("Create owner account")(async () => { + await completeSignupFlow(page, expect, owner, context); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + await step("Navigate to users page as Owner & verify invite button is visible")(async () => { + await page.goto("/admin/users"); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Invite user" })).toBeVisible(); + })(); + + await step("Navigate to account settings as Owner & verify tenant name field is editable")(async () => { + await page.goto("/admin/account"); + + await expect(page.getByRole("heading", { name: "Account settings" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Account name" })).toBeEnabled(); + await expect(page.getByRole("textbox", { name: "Account name" })).not.toHaveAttribute("readonly"); + await expect(page.getByRole("button", { name: "Save changes" })).toBeVisible(); + })(); + + await step("Verify danger zone is visible for Owner")(async () => { + // Danger zone should be visible to Owners + await expect(page.getByRole("heading", { name: "Danger zone" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Delete account" })).toBeVisible(); + await expect( + page.getByText("Delete your account and all data. This action is irreversible—proceed with caution.") + ).toBeVisible(); + })(); + + await step("Open owner's actions menu & verify self-action restrictions")(async () => { + await page.goto("/admin/users"); + + // Wait for page to load + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + + // Wait for table to be present + await page.waitForSelector("tbody tr", { state: "attached" }); + + // Find the owner's own row by looking for the email + const ownerRow = page.locator("tbody tr").filter({ hasText: owner.email }).first(); + + // Click the actions button using JavaScript to bypass visibility checks + const actionsButton = ownerRow.locator("button[aria-label='User actions']").first(); + await actionsButton.evaluate((el: HTMLElement) => el.click()); + + // Verify delete menu item is disabled (self-protection) + await expect(page.getByRole("menuitem", { name: "Delete" })).toBeDisabled(); + // Verify change role menu item is disabled (self-protection) + await expect(page.getByRole("menuitem", { name: "Change role" })).toBeDisabled(); + + // Click outside the menu to close it + await page.locator("body").click({ position: { x: 10, y: 10 } }); + + // Wait for menu to close + await expect(page.getByRole("menu")).not.toBeVisible(); + })(); + + await step("Invite member user")(async () => { + // Invite member user + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(member.email); + await page.getByRole("button", { name: "Send invite" }).click(); + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + + // Ensure the invitation is complete and the page is stable before proceeding + await expect(page.locator("tbody").first()).toContainText(member.email); + })(); + + await step("Log out from owner and log in as member")(async () => { + // Ensure the user table is stable and all users are loaded + await expect(page.locator("tbody").first().locator("tr")).toHaveCount(2); // owner + member + + // Ensure the invite button is visible and the page is fully interactive + await expect(page.getByRole("button", { name: "Invite user" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Invite user" })).toBeEnabled(); + + // Verify user emails are visible in the table to ensure data is loaded + await expect(page.locator("tbody").first()).toContainText(owner.email); + await expect(page.locator("tbody").first()).toContainText(member.email); + + // Mark 401 as expected during logout transition (React Query may have in-flight requests) + context.monitoring.expectedStatusCodes.push(401); + + // Navigate away from users page first to prevent background requests + await page.goto("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + + await page.getByRole("button", { name: "User profile menu" }).click(); + await page.getByRole("menuitem", { name: "Log out" }).click(); + + // Wait for logout to complete and page to navigate + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + + // Accept whatever return path we get + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + + // Login as member + await page.getByRole("textbox", { name: "Email" }).fill(member.email); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); + await page.keyboard.type(getVerificationCode()); + + // Wait for navigation to complete after verification + await page.waitForURL("/admin"); + })(); + + await step("Complete member profile setup")(async () => { + await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); + await page.getByRole("textbox", { name: "First name" }).fill(member.firstName); + await page.getByRole("textbox", { name: "Last name" }).fill(member.lastName); + await page.getByRole("textbox", { name: "Title" }).fill("Team Member"); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(context, "Profile updated successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + })(); + + await step("Navigate to users page as Member & verify invite button is hidden")(async () => { + await page.goto("/admin/users"); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Invite user" })).not.toBeVisible(); + })(); + + await step("Navigate to account settings as Member & verify tenant name field is readonly")(async () => { + await page.goto("/admin/account"); + + // Members should see readonly account name field + await expect(page.getByRole("heading", { name: "Account settings" })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Account name" })).toHaveAttribute("readonly"); + await expect(page.getByText("Only account owners can modify the account name")).toBeVisible(); + await expect(page.getByRole("button", { name: "Save changes" })).not.toBeVisible(); + })(); + + await step("Verify danger zone is hidden for Member")(async () => { + // Danger zone should be hidden from Members + await expect(page.getByRole("heading", { name: "Danger zone" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Delete account" })).not.toBeVisible(); + await expect( + page.getByText("Delete your account and all data. This action is irreversible—proceed with caution.") + ).not.toBeVisible(); + })(); + + await step("Open member's actions menu & verify self-action restrictions")(async () => { + await page.goto("/admin/users"); + + // Wait for page to load + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + + // Find the member's own row by filtering by email - use first() to handle duplicates + const memberRow = page.locator("tbody tr").filter({ hasText: member.email }).first(); + const memberActionsButton = memberRow.locator("button[aria-label='User actions']").first(); + await memberActionsButton.evaluate((el: HTMLElement) => el.click()); + + // Verify delete and change role menu items are not visible (members don't see these options) + await expect(page.getByRole("menuitem", { name: "Delete" })).not.toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Change role" })).not.toBeVisible(); + + // Verify only View profile is available + await expect(page.getByRole("menuitem", { name: "View profile" })).toBeVisible(); + + // Click outside the menu to close it + await page.locator("body").click({ position: { x: 10, y: 10 } }); + })(); + }); + + /** + * BULK DELETE PERMISSION TESTS + * + * Tests that bulk delete functionality is only available to Owners. + */ + test("should show bulk delete controls only for Owners", async ({ page }) => { + const context = createTestContext(page); + const owner = testUser(); + const member = testUser(); + + const user1 = testUser(); + const user2 = testUser(); + + await step("Create owner account")(async () => { + await completeSignupFlow(page, expect, owner, context); + await page.goto("/admin/users"); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + + await step("Invite first test user & verify user appears in table")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(user1.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.locator("tbody").first()).toContainText(user1.email); + })(); + + await step("Invite second test user & verify user appears in table")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(user2.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.locator("tbody").first()).toContainText(user2.email); + })(); + + await step("Invite member user & verify all users are in table")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(member.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.locator("tbody").first()).toContainText(member.email); + // Should now have owner + 3 invited users = 4 total + await expect(page.locator("tbody").first().locator("tr")).toHaveCount(4); + })(); + + await step("Select multiple users as Owner & verify bulk delete button appears")(async () => { + // Select the first two invited users - use first tbody due to mobile rendering + const rows = page.locator("tbody").first().locator("tr"); + const secondRow = rows.nth(1); // First invited user + const thirdRow = rows.nth(2); // Second invited user + + // Select first user using force click to bypass visibility + await secondRow.evaluate((el: HTMLElement) => el.click()); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + + // Select second user with Ctrl/Cmd modifier - use evaluate to simulate click with modifier + await page.keyboard.down("ControlOrMeta"); + await thirdRow.evaluate((el: HTMLElement) => el.click()); + await page.keyboard.up("ControlOrMeta"); + await expect(thirdRow).toHaveAttribute("aria-selected", "true"); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + + // Verify bulk delete button is visible for Owner + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeVisible(); + + // Ensure the selections are stable and the UI has updated + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + await expect(thirdRow).toHaveAttribute("aria-selected", "true"); + })(); + + await step("Log out as owner and log in as member")(async () => { + // Ensure the bulk delete button is still visible and selections are stable + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeEnabled(); + + // Verify that the selected rows are still selected + const allRows = page.locator("tbody").first().locator("tr"); + const secondRow = allRows.nth(1); + const thirdRow = allRows.nth(2); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + await expect(thirdRow).toHaveAttribute("aria-selected", "true"); + + // Mark 401 as expected during logout transition (React Query may have in-flight requests) + context.monitoring.expectedStatusCodes.push(401); + + // Navigate away from users page first to prevent background requests + await page.goto("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + + await page.getByRole("button", { name: "User profile menu" }).click(); + await page.getByRole("menuitem", { name: "Log out" }).click(); + + // Wait for logout to complete and page to navigate + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + + // Accept whatever return path we get + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + + // Login as member + await page.getByRole("textbox", { name: "Email" }).fill(member.email); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByRole("heading", { name: "Enter your verification code" })).toBeVisible(); + await page.keyboard.type(getVerificationCode()); + + // Wait for navigation to complete after verification + await page.waitForURL("/admin"); + })(); + + await step("Complete member profile setup")(async () => { + await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); + await page.getByRole("textbox", { name: "First name" }).fill(member.firstName); + await page.getByRole("textbox", { name: "Last name" }).fill(member.lastName); + await page.getByRole("textbox", { name: "Title" }).fill("Team Member"); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(context, "Profile updated successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + })(); + + await step("Navigate to users page as Member & verify no bulk operations available")(async () => { + await page.goto("/admin/users"); + + // Ensure we can see the users that were created + // Use first tbody due to mobile rendering creating duplicate tables + await expect(page.locator("tbody").first().locator("tr")).toHaveCount(4); + + // Try to select rows (member can still select, but no bulk actions should appear) + // Set viewport to 2xl to avoid side pane backdrop issues + await page.setViewportSize({ width: 1536, height: 1024 }); + + const rows = page.locator("tbody").first().locator("tr"); + const secondRow = rows.nth(1); + const thirdRow = rows.nth(2); + + // Select users as Member using force click to bypass visibility + await secondRow.evaluate((el: HTMLElement) => el.click()); + await expect(secondRow).toHaveAttribute("aria-selected", "true"); + + await page.keyboard.down("ControlOrMeta"); + await thirdRow.evaluate((el: HTMLElement) => el.click()); + await page.keyboard.up("ControlOrMeta"); + await expect(thirdRow).toHaveAttribute("aria-selected", "true"); + + // Verify bulk delete button is NOT visible for Member even with selections + await expect(page.getByRole("button", { name: "Delete 2 users" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Delete user" })).not.toBeVisible(); + + // Reset viewport + await page.setViewportSize({ width: 1280, height: 720 }); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts new file mode 100644 index 0000000000..a8cc11a365 --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts @@ -0,0 +1,292 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { + blurActiveElement, + createTestContext, + expectToastMessage, + expectValidationError +} from "@shared/e2e/utils/test-assertions"; +import { getVerificationCode, testUser, uniqueEmail } from "@shared/e2e/utils/test-data"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +test.describe("@smoke", () => { + test("should handle signup flow with validation, profile setup, and account management", async ({ browser }) => { + // Create two browser contexts to simulate different sessions + const context = await browser.newContext(); + const page = await context.newPage(); + const testContext = createTestContext(page); + const user = testUser(); + + // === SIGNUP INITIATION === + await step("Navigate to signup page")(async () => { + await page.goto("/signup"); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + })(); + + // === EMAIL VALIDATION EDGE CASES === + await step("Submit form with empty email & verify validation error")(async () => { + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); + })(); + + await step("Enter invalid email format & verify validation error")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill("invalid-email"); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); + })(); + + await step("Enter email with consecutive dots & verify validation error")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill("test..user@example.com"); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); + })(); + + await step("Enter email exceeding maximum length & verify validation error")(async () => { + const longEmail = `${"a".repeat(90)}@example.com`; // 101 characters total + await page.getByRole("textbox", { name: "Email" }).fill(longEmail); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup"); + await expect(page.getByText("Email must be in a valid format and no longer than 100 characters.")).toBeVisible(); + })(); + + // === SUCCESSFUL SIGNUP FLOW === + await step("Complete signup with valid email & verify navigation to verification page")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await blurActiveElement(page); + await expect(page.getByText("Europe")).toBeVisible(); + await page.getByRole("button", { name: "Create your account" }).click(); + + // Verify verification page state + await expect(page).toHaveURL("/signup/verify"); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + await expect(page.getByRole("button", { name: "Verify" })).toBeDisabled(); + })(); + + // === VERIFICATION CODE VALIDATION === + await step("Enter wrong verification code & verify error and focus reset")(async () => { + await page.keyboard.type("WRONG1"); // Auto-submits on 6 characters + + await expectToastMessage(testContext, 400, "The code is wrong or no longer valid."); + await expect(page.locator('input[autocomplete="one-time-code"]').first()).toBeFocused(); + })(); + + await step("Type verification code & verify submit button enables")(async () => { + await page.keyboard.type(getVerificationCode()); + + await expect(page.getByRole("button", { name: "Verify" })).toBeEnabled(); + })(); + + await step("Click verify button & verify navigation to admin with profile dialog")(async () => { + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); + })(); + + // === PROFILE FORM VALIDATION & COMPLETION === + await step("Submit profile form with empty fields & verify validation errors appear")(async () => { + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectValidationError(testContext, "First name must be between 1 and 30 characters."); + await expectValidationError(testContext, "Last name must be between 1 and 30 characters."); + })(); + + await step("Fill form with one field too long and one missing & verify all validation errors appear")(async () => { + // Create invalid form data + const longName = "A".repeat(31); + const longTitle = "B".repeat(51); + await page.getByRole("textbox", { name: "First name" }).fill(longName); + await page.getByRole("textbox", { name: "Last name" }).clear(); + await page.getByRole("textbox", { name: "Title" }).fill(longTitle); + await page.getByRole("button", { name: "Save changes" }).click(); + + // Verify all validation errors appear + await expect(page.getByRole("dialog")).toBeVisible(); + await expectValidationError(testContext, "First name must be between 1 and 30 characters."); + await expectValidationError(testContext, "Last name must be between 1 and 30 characters."); + await expectValidationError(testContext, "Title must be no longer than 50 characters."); + })(); + + await step("Complete profile setup with valid data & verify navigation to dashboard")(async () => { + // Complete profile setup + await page.getByRole("textbox", { name: "First name" }).fill(user.firstName); + await page.getByRole("textbox", { name: "Last name" }).fill(user.lastName); + await page.getByRole("textbox", { name: "Title" }).fill("CEO & Founder"); + await page.getByRole("button", { name: "Save changes" }).click(); + + // Verify success + await expectToastMessage(testContext, 200, "Profile updated successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + // === AVATAR & PROFILE FUNCTIONALITY === + await step("Click avatar button & verify it shows initials and profile information")(async () => { + // Verify avatar shows user initials + const initials = user.firstName.charAt(0) + user.lastName.charAt(0); + await expect(page.getByRole("button", { name: "User profile menu" })).toContainText(initials); + + // Open profile menu and verify user info + await page.getByRole("button", { name: "User profile menu" }).click(); + await expect(page.getByText(`${user.firstName} ${user.lastName}`)).toBeVisible(); + await expect(page.getByText("CEO & Founder")).toBeVisible(); + + // Open and close edit dialog + await page.getByRole("menuitem", { name: "Edit profile" }).click(); + await expect(page.getByRole("textbox", { name: "Title" })).toHaveValue("CEO & Founder"); + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByRole("dialog")).not.toBeVisible(); + })(); + + // === AUTHENTICATED NAVIGATION PROTECTION === + await step("Navigate to signup page while authenticated & verify redirect to admin")(async () => { + await page.goto("/signup"); + + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + // === ACCOUNT MANAGEMENT === + await step("Clear account name field & verify validation error appears")(async () => { + await page.getByLabel("Main navigation").getByRole("link", { name: "Account" }).click(); + await expect(page.getByRole("heading", { name: "Account settings" })).toBeVisible(); + await page.getByRole("textbox", { name: "Account name" }).clear(); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectValidationError(testContext, "Name must be between 1 and 30 characters."); + })(); + + await step("Update account name & verify successful save")(async () => { + const newAccountName = `Tech Corp ${Date.now()}`; + await page.getByRole("textbox", { name: "Account name" }).fill(newAccountName); + // WebKit requires explicit focus before clicking + await page.getByRole("button", { name: "Save changes" }).focus(); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(testContext, 200, "Account updated successfully"); + })(); + + await step("Update user profile title & verify successful profile update")(async () => { + await page.getByRole("button", { name: "User profile menu" }).click(); + await page.getByRole("menuitem", { name: "Edit profile" }).click(); + await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); + await page.getByRole("textbox", { name: "Title" }).fill("Chief Executive Officer"); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(testContext, 200, "Profile updated successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + })(); + + await step("Navigate to account page")(async () => { + await page.getByLabel("Main navigation").getByRole("link", { name: "Account" }).click(); + + await expect(page.getByRole("textbox", { name: "Account name" })).toBeVisible(); + })(); + + // Cleanup explicitly created browser context + await context.close(); + }); +}); + +test.describe("@comprehensive", () => { + // Rate limiting for verification attempts is comprehensively tested in login-flows.spec.ts + + test("should show detailed error message when too many signup attempts are made", async ({ page }) => { + const context = createTestContext(page); + const testEmail = uniqueEmail(); + + await step("Make 3 signup attempts & verify each navigates to verify page")(async () => { + // Make 3 signup attempts within rate limit threshold + for (let attempt = 1; attempt <= 3; attempt++) { + await page.goto("/signup"); + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Email" }).fill(testEmail); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup/verify"); + } + })(); + + await step("Make 4th signup attempt & verify rate limiting triggers")(async () => { + await page.goto("/signup"); + await expect(page.getByRole("heading", { name: "Create your account" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Email" }).fill(testEmail); + await page.getByRole("button", { name: "Create your account" }).click(); + + // Verify rate limiting prevents navigation + await expect(page).toHaveURL("/signup"); + await expectToastMessage( + context, + 429, + "Too many attempts to confirm this email address. Please try again later." + ); + })(); + }); +}); + +test.describe("@slow", () => { + const requestNewCodeTimeout = 30_000; // 30 seconds + const codeValidationTimeout = 300_000; // 5 minutes (300 seconds) + const sessionTimeout = codeValidationTimeout + 60_000; // 6 minutes total + + test("should allow resend code 30 seconds after signup but then not after code has expired", async ({ page }) => { + test.setTimeout(sessionTimeout); + const context = createTestContext(page); + const user = testUser(); + + await step("Start signup and navigate to verify & verify page displays")(async () => { + await page.goto("/signup"); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await blurActiveElement(page); + await page.getByRole("button", { name: "Create your account" }).click(); + + await expect(page).toHaveURL("/signup/verify"); + await expect(page.getByText("Can't find your code? Check your spam folder.").first()).toBeVisible(); + })(); + + await step("Wait 30 seconds & verify request code button appears")(async () => { + await page.waitForTimeout(requestNewCodeTimeout); + + // Verify UI changes after timeout + await expect( + page.getByRole("textbox", { name: "Can't find your code? Check your spam folder." }) + ).not.toBeVisible(); + await expect(page.getByText("Request a new code")).toBeVisible(); + })(); + + await step("Click request new code & verify success message and button hides")(async () => { + await page.getByRole("button", { name: "Request a new code" }).click(); + + await expectToastMessage(context, "A new verification code has been sent to your email."); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + + await step("Wait for code expiration & verify expiration message displays")(async () => { + await page.waitForTimeout(codeValidationTimeout); + + // Verify expiration state + await expect(page).toHaveURL("/signup/verify"); + await expect(page.getByText("Your verification code has expired")).toBeVisible(); + await expect(page.getByRole("button", { name: "Request a new code" })).not.toBeVisible(); + await expect(page.getByText("Can't find your code? Check your spam folder.")).toBeVisible(); + })(); + }); + + // 5-minute request new code test is already covered in login-flows.spec.ts +}); diff --git a/application/account-management/WebApp/tests/e2e/theme-flows.spec.ts b/application/account-management/WebApp/tests/e2e/theme-flows.spec.ts new file mode 100644 index 0000000000..89ef6128e8 --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/theme-flows.spec.ts @@ -0,0 +1,269 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { createTestContext } from "@shared/e2e/utils/test-assertions"; +import { getVerificationCode } from "@shared/e2e/utils/test-data"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +test.describe("@comprehensive", () => { + /** + * Tests theme switching functionality across different viewport sizes and authentication states. + * Covers: + * - Theme switching between light, dark, and system modes + * - Theme persistence across page reloads + * - Theme persistence across logout/login cycles + * - Theme behavior at different viewport sizes (mobile, tablet, desktop, 4K) + * - Sidebar collapse/expand states with theme changes + */ + test("should handle theme switching with persistence across viewport sizes", async ({ ownerPage }) => { + createTestContext(ownerPage); + + await step("Navigate to admin dashboard & verify default light theme")(async () => { + await ownerPage.goto("/admin"); + + // Verify dashboard loads with default light theme + await expect(ownerPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(ownerPage.locator("html")).not.toHaveClass("dark"); + })(); + + await step("Click theme button and select dark mode & verify dark theme applies")(async () => { + const themeButton = ownerPage.getByRole("button", { name: "Change theme" }); + await themeButton.click(); + + // Wait for menu to open + const themeMenu = ownerPage.getByRole("menu"); + await expect(themeMenu).toBeVisible(); + + // Click dark theme option and wait for menu to close + await ownerPage.getByRole("menuitem", { name: "Dark" }).click(); + + await expect(themeMenu).not.toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass("dark"); + })(); + + await step("Reload page & verify dark theme persists")(async () => { + await ownerPage.reload(); + + // Verify theme persists after reload + await expect(ownerPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass("dark"); + })(); + + await step("Navigate to users page & verify dark theme remains active")(async () => { + await ownerPage.getByLabel("Main navigation").getByRole("link", { name: "Users" }).click(); + + // Verify theme persists across navigation + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass("dark"); + })(); + + await step("Click theme button and select system mode & verify theme follows system preference")(async () => { + const themeButton = ownerPage.getByRole("button", { name: "Change theme" }); + await themeButton.click(); + + // Wait for menu to open + const systemMenu = ownerPage.getByRole("menu"); + await expect(systemMenu).toBeVisible(); + + await ownerPage.getByRole("menuitem", { name: "System" }).click(); + + await expect(systemMenu).not.toBeVisible(); + // System theme will be light in test environment + await expect(ownerPage.locator("html")).not.toHaveClass("dark"); + })(); + + await step("Resize to 4K viewport & verify theme handling at large resolution")(async () => { + await ownerPage.setViewportSize({ width: 2560, height: 1440 }); + + // Verify 4K layout and theme state + const themeButton = ownerPage.getByRole("button", { name: "Change theme" }); + await expect(themeButton).toBeVisible(); + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(ownerPage.locator("html")).not.toHaveClass("dark"); + })(); + + await step("Click theme button and select dark at 4K & verify theme applies")(async () => { + await ownerPage.getByRole("button", { name: "Change theme" }).click(); + + // Wait for menu to open before clicking + const menu4k = ownerPage.getByRole("menu"); + await expect(menu4k).toBeVisible(); + + await ownerPage.getByRole("menuitem", { name: "Dark" }).click(); + + await expect(ownerPage.locator("html")).toHaveClass("dark"); + })(); + + await step("Resize to tablet viewport & verify theme persists with responsive layout")(async () => { + await ownerPage.setViewportSize({ width: 768, height: 1024 }); + + // Verify tablet layout and theme persistence + await expect(ownerPage.getByRole("button", { name: "Change theme" })).toBeVisible(); + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass("dark"); + })(); + + await step("Collapse sidebar at tablet size & verify theme button remains accessible")(async () => { + const toggleButton = ownerPage.getByRole("button", { name: "Toggle sidebar" }); + await expect(toggleButton).toBeVisible(); + + await toggleButton.click(); + + // Theme button in top menu should still be visible + await expect(ownerPage.getByRole("button", { name: "Change theme" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass("dark"); + })(); + + await step("Resize to mobile viewport & verify theme menu in mobile navigation")(async () => { + await ownerPage.setViewportSize({ width: 375, height: 667 }); + + // Verify mobile layout + await expect(ownerPage.getByRole("button", { name: "Open navigation menu" })).toBeVisible(); + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(ownerPage.locator("html")).toHaveClass("dark"); + + // Theme button should not be visible in top menu on mobile + await expect(ownerPage.getByRole("button", { name: "Change theme" })).not.toBeVisible(); + + // Open mobile menu and verify theme option + await ownerPage.getByRole("button", { name: "Open navigation menu" }).click(); + await expect(ownerPage.getByRole("dialog", { name: "Mobile navigation menu" })).toBeVisible(); + await expect(ownerPage.getByRole("button", { name: "Theme" })).toBeVisible(); + })(); + + await step("Change theme via mobile menu & verify theme updates")(async () => { + await ownerPage.getByRole("button", { name: "Theme" }).click(); + + // Wait for theme submenu to open + const themeSubmenu = ownerPage.getByRole("menu"); + await expect(themeSubmenu).toBeVisible(); + + // Select light theme + await ownerPage.getByRole("menuitem", { name: "Light" }).click(); + + // Mobile menu should close automatically + await expect(ownerPage.getByRole("dialog", { name: "Mobile navigation menu" })).not.toBeVisible(); + + // Verify light theme is applied + await expect(ownerPage.locator("html")).not.toHaveClass("dark"); + })(); + + await step("Return to desktop viewport & verify theme persists")(async () => { + await ownerPage.setViewportSize({ width: 1920, height: 1080 }); + + // Verify desktop layout restoration + await expect(ownerPage.getByRole("button", { name: "Change theme" })).toBeVisible(); + await expect(ownerPage.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(ownerPage.locator("html")).not.toHaveClass("dark"); + await expect(ownerPage.getByRole("button", { name: "Toggle sidebar" })).toBeVisible(); + })(); + + await step("Set dark theme before logout & verify theme applies")(async () => { + await ownerPage.getByRole("button", { name: "Change theme" }).click(); + + // Wait for menu to open before clicking + const menu4k = ownerPage.getByRole("menu"); + await expect(menu4k).toBeVisible(); + + await ownerPage.getByRole("menuitem", { name: "Dark" }).click(); + + await expect(ownerPage.locator("html")).toHaveClass("dark"); + })(); + + await step("Open new browser tab & verify dark theme persists across sessions")(async () => { + // Open a new tab in the same context to verify theme persistence + const newPage = await ownerPage.context().newPage(); + await newPage.goto("/admin"); + + await expect(newPage.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + await expect(newPage.locator("html")).toHaveClass("dark"); + + await newPage.close(); + })(); + }); + + /** + * Tests theme persistence across logout and login cycles. + * Verifies that user theme preferences are maintained when logging out and back in. + */ + test("Change theme to dark, logout and login back & verify theme persists", async ({ anonymousPage }) => { + const { page, tenant } = anonymousPage; + const existingUser = tenant.owner; + const context = createTestContext(page); + + await step("Log in as owner & verify navigation to admin")(async () => { + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + + await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify"); + + // Wait for verification input to be ready + const verificationInput = page.locator('input[autocomplete="one-time-code"]').first(); + await expect(verificationInput).toBeVisible(); + await verificationInput.focus(); + + // Auto-submits on first login + await page.keyboard.type(getVerificationCode()); + + // Wait for auto-submit to complete + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + await step("Click theme button and select dark mode & verify it applies")(async () => { + const themeButton = page.getByRole("button", { name: "Change theme" }); + await themeButton.click(); + + // Wait for menu to open + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + + await page.getByRole("menuitem", { name: "Dark" }).click(); + + await expect(page.locator("html")).toHaveClass("dark"); + })(); + + await step("Log out & verify dark theme persists on login page")(async () => { + // Mark 401 as expected during logout transition (React Query may have in-flight requests) + context.monitoring.expectedStatusCodes.push(401); + + await page.getByRole("button", { name: "User profile menu" }).click(); + + // Wait for user menu to open + const userMenu = page.getByRole("menu"); + await expect(userMenu).toBeVisible(); + + await page.getByRole("menuitem", { name: "Log out" }).click(); + + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + await expect(page.getByRole("heading", { name: "Hi! Welcome back" })).toBeVisible(); + + // Dark theme should persist after logout + await expect(page.locator("html")).toHaveClass("dark"); + })(); + + await step("Log back in & verify theme remains dark after authentication")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(existingUser.email); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); + + // Wait for verification input to be ready + const verificationInput = page.locator('input[autocomplete="one-time-code"]').first(); + await expect(verificationInput).toBeVisible(); + await verificationInput.focus(); + + // Auto-submits on first login + await page.keyboard.type(getVerificationCode()); + + // Wait for auto-submit to complete + await expect(page).toHaveURL("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + + // Dark theme should persist after login + await expect(page.locator("html")).toHaveClass("dark"); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts new file mode 100644 index 0000000000..ac88078833 --- /dev/null +++ b/application/account-management/WebApp/tests/e2e/user-management-flows.spec.ts @@ -0,0 +1,513 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { createTestContext, expectToastMessage } from "@shared/e2e/utils/test-assertions"; +import { completeSignupFlow, getVerificationCode, testUser } from "@shared/e2e/utils/test-data"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +test.describe("@smoke", () => { + /** + * COMPREHENSIVE USER MANAGEMENT WORKFLOW + * + * Tests the complete end-to-end user management journey including: + * - User invitation process with validation (invalid email, duplicate email) + * - Role management (changing user roles from Member to Admin) + * - Permission system (testing what owners vs admins can/cannot do) + * - Search and filtering functionality (email search, role filtering) + * - User permission restrictions (what owners vs admins can/cannot do) + */ + test("should handle user invitation, role management & permissions workflow", async ({ page }) => { + const context = createTestContext(page); + const owner = testUser(); + const adminUser = testUser(); + const memberUser = testUser(); + + await step("Complete owner signup")(async () => { + await completeSignupFlow(page, expect, owner, context); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + await step("Navigate to users page & verify owner is listed")(async () => { + await page.getByLabel("Main navigation").getByRole("link", { name: "Users" }).click(); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + // Wait for table to load and verify content exists - use first() due to mobile rendering with duplicate tables + await expect(page.locator("tbody").first().first()).toContainText(owner.email); + await expect(page.locator("tbody").first().first()).toContainText("Owner"); + })(); + + // Email validation is comprehensively tested in signup-flows.spec.ts + + await step("Invite member user & verify successful invitation")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await expect(page.getByRole("dialog", { name: "Invite user" })).toBeVisible(); + await page.getByRole("textbox", { name: "Email" }).fill(memberUser.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + // Verify both users exist in table - use first() due to mobile rendering with duplicate tables + await expect(page.locator("tbody").first().first()).toContainText(memberUser.email); + await expect(page.locator("tbody").first().first()).toContainText(owner.email); + })(); + + await step("Invite admin user & verify successful invitation")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(adminUser.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + // Verify all three users exist in table - use first() due to mobile rendering with duplicate tables + await expect(page.locator("tbody").first().first()).toContainText(adminUser.email); + await expect(page.locator("tbody").first().first()).toContainText(memberUser.email); + await expect(page.locator("tbody").first().first()).toContainText(owner.email); + })(); + + await step("Open actions menu for admin user and change role to Admin & verify role updates")(async () => { + const adminUserRow = page.locator("tbody").first().locator("tr").filter({ hasText: adminUser.email }); + const actionsButton = adminUserRow.locator("button[aria-label='User actions']").first(); + await actionsButton.evaluate((el: HTMLElement) => el.click()); + + // Wait for menu to be visible before clicking + await expect(page.getByRole("menu")).toBeVisible(); + await page.getByRole("menuitem", { name: "Change role" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Change user role" })).toBeVisible(); + await page.getByRole("button", { name: "Member User role" }).click(); + await page.getByRole("option", { name: "Admin" }).click(); + await page.getByRole("button", { name: "OK" }).click(); + + await expectToastMessage(context, `User role updated successfully for ${adminUser.email}`); + + // Wait for dialog to close + await expect(page.getByRole("alertdialog", { name: "Change user role" })).not.toBeVisible(); + await expect(adminUserRow.first()).toContainText("Admin"); + })(); + + await step("Attempt to invite duplicate user email & verify error message appears")(async () => { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(memberUser.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, 400, `The user with '${memberUser.email}' already exists.`); + + await page.getByRole("button", { name: "Cancel" }).click(); + + // Wait for dialog to close + await expect(page.getByRole("dialog")).not.toBeVisible(); + })(); + + await step("Check users table & verify invited users appear with correct roles")(async () => { + // Set viewport to ensure role badges are visible + await page.setViewportSize({ width: 1280, height: 720 }); + + const userTable = page.locator("tbody").first().first(); + // Verify all users are visible without counting rows due to mobile rendering differences + await expect(userTable).toContainText(adminUser.email); + await expect(userTable).toContainText(memberUser.email); + await expect(userTable).toContainText(owner.email); + })(); + + await step("Open owner's actions menu & verify self-deletion and role change are disabled")(async () => { + const ownerRowSelf = page.locator("tbody").first().locator("tr").filter({ hasText: owner.email }); + const ownerActionsButton = ownerRowSelf.locator("button[aria-label='User actions']").first(); + await ownerActionsButton.evaluate((el: HTMLElement) => el.click()); + + await expect(page.getByRole("menuitem", { name: "Delete" })).toBeDisabled(); + await expect(page.getByRole("menuitem", { name: "Change role" })).toBeDisabled(); + + // Click outside the menu to close it + await page.locator("body").click({ position: { x: 10, y: 10 } }); + })(); + + await step("Filter users by email search & verify filtered results display correctly")(async () => { + // Ensure viewport is desktop size for search to be visible + await page.setViewportSize({ width: 1280, height: 720 }); + + const userTable = page.locator("tbody").first(); + + // Search for admin user + const searchInput = page.getByRole("searchbox", { name: "Search" }); + await searchInput.fill(adminUser.email); + await page.keyboard.press("Enter"); // Trigger search immediately without debounce + + // Verify only admin user is shown without counting rows + await expect(userTable).toContainText(adminUser.email); + await expect(userTable).not.toContainText(owner.email); + await expect(userTable).not.toContainText(memberUser.email); + + // Clear search and verify all users are shown again + await searchInput.clear(); + await page.keyboard.press("Enter"); // Trigger search immediately to show all results + + await expect(userTable).toContainText(adminUser.email); + await expect(userTable).toContainText(memberUser.email); + await expect(userTable).toContainText(owner.email); + })(); + + await step("Filter users by role & verify role-based filtering works correctly")(async () => { + const userTable = page.locator("tbody").first().first(); + await page.getByRole("button", { name: "Show filters" }).click(); + await page.getByRole("button", { name: "Any role User role" }).click(); + await page.getByRole("option", { name: "Owner" }).click(); + + // Verify only owner is shown without counting rows + await expect(userTable).toContainText(owner.email); + await expect(userTable).not.toContainText(adminUser.email); + await expect(userTable).not.toContainText(memberUser.email); + + await page.getByRole("button", { name: "Owner User role" }).click(); + await page.getByRole("option", { name: "Any role" }).click(); + + // Verify all users are shown again + await expect(userTable).toContainText(adminUser.email); + await expect(userTable).toContainText(memberUser.email); + await expect(userTable).toContainText(owner.email); + })(); + + await step("Logout from owner account")(async () => { + // Mark 401 as expected during logout transition (React Query may have in-flight requests) + context.monitoring.expectedStatusCodes.push(401); + + // Navigate to home first + await page.goto("/admin"); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + + await page.getByRole("button", { name: "User profile menu" }).click(); + await page.getByRole("menuitem", { name: "Log out" }).click(); + + await expect(page).toHaveURL("/login?returnPath=%2Fadmin"); + })(); + + await step("Login as admin user & verify successful authentication")(async () => { + await page.getByRole("textbox", { name: "Email" }).fill(adminUser.email); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page).toHaveURL("/login/verify?returnPath=%2Fadmin"); + await page.keyboard.type(getVerificationCode()); + + await expect(page).toHaveURL("/admin"); + })(); + + await step("Complete admin user profile setup")(async () => { + await expect(page.getByRole("dialog", { name: "User profile" })).toBeVisible(); + await page.getByRole("textbox", { name: "First name" }).fill(adminUser.firstName); + await page.getByRole("textbox", { name: "Last name" }).fill(adminUser.lastName); + await page.getByRole("textbox", { name: "Title" }).fill("Administrator"); + await page.getByRole("button", { name: "Save changes" }).click(); + + await expectToastMessage(context, "Profile updated successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + await expect(page.getByRole("heading", { name: "Welcome home" })).toBeVisible(); + })(); + + await step("Navigate to users page as admin & verify admin can see all users")(async () => { + await page.getByLabel("Main navigation").getByRole("link", { name: "Users" }).click(); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + // Use first tbody due to mobile rendering with duplicate tables + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); // owner + admin + member + })(); + + await step("Open member user menu as admin & verify limited actions available")(async () => { + const memberUserRow = page.locator("tbody").first().locator("tr").filter({ hasText: memberUser.email }); + const memberActionsButton = memberUserRow.locator("button[aria-label='User actions']").first(); + await memberActionsButton.evaluate((el: HTMLElement) => el.click()); + + // Admin users don't see Change role or Delete options - only View profile + await expect(page.getByRole("menuitem", { name: "View profile" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Change role" })).not.toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Delete" })).not.toBeVisible(); + })(); + }); +}); + +test.describe("@comprehensive", () => { + /** + * USER DELETION WORKFLOWS WITH DASHBOARD INTEGRATION + * + * Tests comprehensive user deletion functionality with dashboard context including: + * - Dashboard metrics integration (user count displays) + * - URL-based filtering (active users link) + * - Single user deletion via menu actions + * - Bulk user selection by clicking rows with Ctrl/Cmd modifier + * - Bulk deletion of multiple users via "Delete X users" button + * - Owner protection mechanisms (deletion restrictions) + * - UI state management after deletions (selection clearing, button visibility) + */ + test("should handle single and bulk user deletion workflows with dashboard integration", async ({ page }) => { + const context = createTestContext(page); + const owner = testUser(); + const user1 = testUser(); + const user2 = testUser(); + const user3 = testUser(); + + // === USER SETUP SECTION === + await step("Complete owner signup")(async () => { + await completeSignupFlow(page, expect, owner, context); + await page.goto("/admin/users"); + + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + })(); + + await step("Invite multiple users & verify they are added to the list")(async () => { + const usersToInvite = [user1, user2, user3]; + + for (const user of usersToInvite) { + await page.getByRole("button", { name: "Invite user" }).click(); + await page.getByRole("textbox", { name: "Email" }).fill(user.email); + await page.getByRole("button", { name: "Send invite" }).click(); + + await expectToastMessage(context, "User invited successfully"); + await expect(page.getByRole("dialog")).not.toBeVisible(); + } + + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(4); // owner + 3 invited users + })(); + + // === DASHBOARD METRICS SECTION === + await step("Navigate to dashboard & verify user count metrics display correctly")(async () => { + await page.goto("/admin"); + + // Verify dashboard shows correct user counts + await expect(page.getByRole("link", { name: "View users" })).toContainText("4"); + await expect(page.getByRole("link", { name: "View active users" })).toContainText("1"); + await expect(page.getByRole("link", { name: "View invited users" })).toContainText("3"); + })(); + + // === URL FILTERING SECTION === + await step("Click invited users link & verify URL filtering works correctly")(async () => { + await page.getByRole("link", { name: "View invited users" }).click(); + + // Verify filtering by URL parameter + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); + await expect(page.url()).toContain("userStatus=Pending"); + })(); + + // === ADVANCED FILTERING SECTION === + await step("Verify all filter options are available")(async () => { + // Filters should already be visible due to URL filtering from previous step + await expect(page.getByLabel("User role").first()).toBeVisible(); + await expect(page.getByLabel("User status").first()).toBeVisible(); + await expect(page.getByLabel("Modified date").first()).toBeVisible(); + })(); + + await step("Filter by Owner role & verify only owner shown")(async () => { + // First dismiss any open dropdown and clear status filter + await page.keyboard.press("Escape"); + await page.getByLabel("User status").first().click(); + await page.getByRole("option", { name: "Any status" }).click(); + + // Now filter by Owner role + await page.getByLabel("User role").first().click(); + await page.getByRole("option", { name: "Owner" }).click(); + + // Verify only owner is shown + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(1); + await expect(page.locator("tbody").first()).toContainText(owner.email); + await expect(page.locator("tbody").first()).not.toContainText(user1.email); + await expect(page.locator("tbody").first()).not.toContainText(user2.email); + })(); + + await step("Filter by Member role & verify only members shown")(async () => { + // Change filter to Member role + await page.getByLabel("User role").first().click(); + await page.getByRole("option", { name: "Member" }).click(); + + // Verify only member users are shown + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); + await expect(page.locator("tbody").first()).toContainText(user1.email); + await expect(page.locator("tbody").first()).toContainText(user2.email); + await expect(page.locator("tbody").first()).toContainText(user3.email); + await expect(page.locator("tbody").first()).not.toContainText(owner.email); + })(); + + await step("Filter by Pending status & verify only pending users shown")(async () => { + // Reset role filter and set status filter + await page.getByLabel("User role").first().click(); + await page.getByRole("option", { name: "Any role" }).click(); + + await page.getByLabel("User status").first().click(); + await page.getByRole("option", { name: "Pending" }).click(); + + // Verify only pending users are shown (invited users who haven't confirmed) + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); + await expect(page.locator("tbody").first()).toContainText(user1.email); + await expect(page.locator("tbody").first()).toContainText(user2.email); + await expect(page.locator("tbody").first()).toContainText(user3.email); + await expect(page.locator("tbody").first()).not.toContainText(owner.email); + })(); + + await step("Filter by Active status & verify only active users shown")(async () => { + // Change filter to Active status + await page.getByLabel("User status").first().click(); + await page.getByRole("option", { name: "Active" }).click(); + + // Verify only active users are shown (owner who has confirmed email) + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(1); + await expect(page.locator("tbody").first()).toContainText(owner.email); + await expect(page.locator("tbody").first()).not.toContainText(user1.email); + })(); + + await step("Filter by past date range & verify no users shown")(async () => { + // Reset status filter first + await page.getByLabel("User status").first().click(); + await page.getByRole("option", { name: "Any status" }).click(); + + // Open date picker + await page.getByLabel("Modified date").first().click(); + + // Set start date to January 1, 2024 + await page.locator('[role="spinbutton"][aria-label="month, Start Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="month, Start Date, "]').type("01"); + await page.locator('[role="spinbutton"][aria-label="day, Start Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="day, Start Date, "]').type("01"); + await page.locator('[role="spinbutton"][aria-label="year, Start Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="year, Start Date, "]').type("2024"); + + // Set end date to December 31, 2024 + await page.locator('[role="spinbutton"][aria-label="month, End Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="month, End Date, "]').type("12"); + await page.locator('[role="spinbutton"][aria-label="day, End Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="day, End Date, "]').type("31"); + await page.locator('[role="spinbutton"][aria-label="year, End Date, "]').clear(); + await page.locator('[role="spinbutton"][aria-label="year, End Date, "]').type("2024"); + + // Close the calendar + await page.keyboard.press("Escape"); + + // Verify no users are shown for the past date range (users were created in 2025) + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(0); + })(); + + // === CLEAR FILTERS FOR CLEAN DELETION TESTS === + await step("Clear all filters & verify all users shown again for clean deletion tests")(async () => { + // Reset any remaining filters to show all users + await page.getByRole("button", { name: "Clear filters" }).click(); + + // Verify all users are shown again + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(4); + await expect(page.locator("tbody").first()).toContainText(owner.email); + await expect(page.locator("tbody").first()).toContainText(user1.email); + await expect(page.locator("tbody").first()).toContainText(user2.email); + await expect(page.locator("tbody").first()).toContainText(user3.email); + })(); + + // === SINGLE USER DELETION SECTION === + await step("Delete single user via menu & verify removal")(async () => { + // Open actions menu and delete user1 + const user1Row = page.locator("tbody").first().locator("tr").filter({ hasText: user1.email }); + const user1ActionsButton = user1Row.locator("button[aria-label='User actions']").first(); + await user1ActionsButton.evaluate((el: HTMLElement) => el.click()); + await page.getByRole("menuitem", { name: "Delete" }).click(); + + // Confirm deletion + await expect(page.getByRole("alertdialog", { name: "Delete user" })).toBeVisible(); + await expect(page.getByText(`Are you sure you want to delete ${user1.email}?`)).toBeVisible(); + await page.getByRole("button", { name: "Delete" }).click(); + + // Verify user is removed from table + await expectToastMessage(context, `User deleted successfully: ${user1.email}`); + await expect(page.getByRole("alertdialog")).not.toBeVisible(); + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); // owner + user2 + user3 + await expect(page.getByText(user1.email)).not.toBeVisible(); + await expect(page.locator("tbody").first()).toContainText(owner.email); + await expect(page.locator("tbody").first()).toContainText(user2.email); + await expect(page.locator("tbody").first()).toContainText(user3.email); + await expect(page.locator("tbody").first().locator("tr")).toHaveCount(3); + })(); + + // === BULK USER SELECTION SECTION === + await step("Select remaining two users by clicking rows & verify selection state")(async () => { + // Use JavaScript evaluation to click rows since regular click is not working + const allRows = page.locator("tbody").first().locator("tr"); + + // Select first non-owner user (index 1) + const user2Row = allRows.nth(1); + await user2Row.evaluate((el: HTMLElement) => el.click()); + await expect(user2Row).toHaveAttribute("aria-selected", "true"); + + // Verify the toolbar delete button is visible (single user selection) + await expect(page.getByRole("button", { name: "Delete user" }).first()).toBeVisible(); + + // Select second non-owner user (index 2) with Ctrl/Cmd modifier + const user3Row = allRows.nth(2); + await page.keyboard.down("ControlOrMeta"); + await user3Row.evaluate((el: HTMLElement) => el.click()); + await page.keyboard.up("ControlOrMeta"); + + // Verify the bulk delete button is visible + await expect(user3Row).toHaveAttribute("aria-selected", "true"); + await expect(user2Row).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeVisible(); + })(); + + await step("Select owner with Ctrl/Cmd modifier & verify delete button becomes disabled")(async () => { + const ownerRow = page.locator("tbody").first().locator("tr").filter({ hasText: owner.email }); + + // Select owner by clicking the row with Ctrl/Cmd modifier + await page.keyboard.down("ControlOrMeta"); + await ownerRow.evaluate((el: HTMLElement) => el.click()); + await page.keyboard.up("ControlOrMeta"); + + // Verify the toolbar bulk delete button is visible but disabled (owner protection) + await expect(page.getByRole("button", { name: "Delete 3 users" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Delete 3 users" })).toBeDisabled(); + })(); + + await step("Deselect owner with Ctrl/Cmd modifier & verify delete button becomes enabled")(async () => { + const ownerRow = page.locator("tbody").first().locator("tr").filter({ hasText: owner.email }); + + // Deselect owner with Ctrl/Cmd modifier + await page.keyboard.down("ControlOrMeta"); + await ownerRow.evaluate((el: HTMLElement) => el.click()); + await page.keyboard.up("ControlOrMeta"); + + // Verify the toolbar bulk delete button is now enabled + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeEnabled(); + })(); + + // === BULK USER DELETION SECTION === + await step("Cancel bulk deletion & verify users remain selected")(async () => { + await page.getByRole("button", { name: "Delete 2 users" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Delete users" })).toBeVisible(); + await expect(page.getByText("Are you sure you want to delete 2 users?")).toBeVisible(); + + await page.getByRole("button", { name: "Cancel" }).click(); + + await expect(page.getByRole("alertdialog")).not.toBeVisible(); + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(3); // All users still present + await expect(page.getByRole("button", { name: "Delete 2 users" })).toBeVisible(); // Selection maintained + })(); + + await step("Confirm bulk delete selected users & verify removal")(async () => { + await page.getByRole("button", { name: "Delete 2 users" }).click(); + + await expect(page.getByRole("alertdialog", { name: "Delete users" })).toBeVisible(); + await page.getByRole("button", { name: "Delete" }).click(); + + await expectToastMessage(context, "2 users deleted successfully"); + await expect(page.getByRole("alertdialog")).not.toBeVisible(); + await expect(page.locator("tbody").first().first().locator("tr")).toHaveCount(1); // Only owner left + await expect(page.getByText(user2.email)).not.toBeVisible(); + await expect(page.getByText(user3.email)).not.toBeVisible(); + await expect(page.locator("tbody").first()).toContainText(owner.email); + await expect(page.getByRole("button", { name: "Delete 2 users" })).not.toBeVisible(); + await expect(page.getByRole("button", { name: "Invite user" })).toBeVisible(); + })(); + + // === OWNER PROTECTION SECTION === + await step("Verify owner menu delete option is disabled")(async () => { + const ownerRow = page.locator("tbody").first().locator("tr").filter({ hasText: owner.email }); + const ownerActionsButton = ownerRow.locator("button[aria-label='User actions']").first(); + await ownerActionsButton.evaluate((el: HTMLElement) => el.click()); + + await expect(page.getByRole("menuitem", { name: "Delete" })).toBeDisabled(); + + // Click outside the menu to close it + await page.locator("body").click({ position: { x: 10, y: 10 } }); + })(); + }); +}); diff --git a/application/account-management/WebApp/tests/playwright.config.ts b/application/account-management/WebApp/tests/playwright.config.ts new file mode 100644 index 0000000000..6256a1e952 --- /dev/null +++ b/application/account-management/WebApp/tests/playwright.config.ts @@ -0,0 +1,11 @@ +/// +import { defineConfig } from "@playwright/test"; +import baseConfig from "../../../shared-webapp/tests/e2e/playwright.config"; +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...baseConfig, + testDir: ".", + testMatch: "**/*.spec.ts" +}); diff --git a/application/account-management/WebApp/tests/tsconfig.json b/application/account-management/WebApp/tests/tsconfig.json new file mode 100644 index 0000000000..7ece77c7c1 --- /dev/null +++ b/application/account-management/WebApp/tests/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Account Management E2E Tests", + "extends": "@repo/config/typescript/react-app.json", + "compilerOptions": { + "types": ["node", "@playwright/test"], + "target": "ES2022", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "paths": { + "@/*": ["../*"], + "@shared/e2e/fixtures/*": ["../../../shared-webapp/tests/e2e/fixtures/*"], + "@shared/e2e/utils/*": ["../../../shared-webapp/tests/e2e/utils/*"], + "@shared/e2e/auth/*": ["../../../shared-webapp/tests/e2e/auth/*"], + "@shared/e2e/types/*": ["../../../shared-webapp/tests/e2e/types/*"] + } + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/application/account-management/WebApp/tsconfig.json b/application/account-management/WebApp/tsconfig.json index cfa0b5464c..1581d50de0 100644 --- a/application/account-management/WebApp/tsconfig.json +++ b/application/account-management/WebApp/tsconfig.json @@ -7,5 +7,5 @@ "@/*": ["./*"] } }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "tests/**"] } diff --git a/application/back-office/WebApp/rsbuild.config.ts b/application/back-office/WebApp/rsbuild.config.ts index b34e8d99f0..6c3e40deb1 100644 --- a/application/back-office/WebApp/rsbuild.config.ts +++ b/application/back-office/WebApp/rsbuild.config.ts @@ -11,6 +11,14 @@ import { pluginTypeCheck } from "@rsbuild/plugin-type-check"; const customBuildEnv: CustomBuildEnv = {}; export default defineConfig({ + tools: { + rspack: { + // Exclude tests/e2e directory from file watching to prevent hot reloading issues + watchOptions: { + ignored: ["**/tests/**", "**/playwright-report/**"] + } + } + }, plugins: [ pluginReact(), pluginTypeCheck(), diff --git a/application/back-office/WebApp/tests/e2e/homepage.spec.ts b/application/back-office/WebApp/tests/e2e/homepage.spec.ts new file mode 100644 index 0000000000..3aafe13f97 --- /dev/null +++ b/application/back-office/WebApp/tests/e2e/homepage.spec.ts @@ -0,0 +1,19 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { createTestContext } from "@shared/e2e/utils/test-assertions"; +import {} from "@shared/e2e/utils/test-data"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +test.describe("@smoke", () => { + test("Navigate to back-office & verify homepage loads correctly", async ({ ownerPage }) => { + createTestContext(ownerPage); + + await step("Navigate to back-office & verify homepage displays welcome message")(async () => { + await ownerPage.goto("/back-office"); + + await expect(ownerPage).toHaveURL("/back-office"); + await expect(ownerPage.getByRole("heading", { name: "Welcome to the Back Office" })).toBeVisible(); + await expect(ownerPage.getByText("Manage tenants, view system data")).toBeVisible(); + })(); + }); +}); diff --git a/application/back-office/WebApp/tests/playwright.config.ts b/application/back-office/WebApp/tests/playwright.config.ts new file mode 100644 index 0000000000..83e7b98260 --- /dev/null +++ b/application/back-office/WebApp/tests/playwright.config.ts @@ -0,0 +1,12 @@ +/// +import { defineConfig } from "@playwright/test"; +import baseConfig from "../../../shared-webapp/tests/e2e/playwright.config"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + ...baseConfig, + testDir: ".", + testMatch: "**/*.spec.ts" +}); diff --git a/application/back-office/WebApp/tests/tsconfig.json b/application/back-office/WebApp/tests/tsconfig.json new file mode 100644 index 0000000000..0bc37c34b0 --- /dev/null +++ b/application/back-office/WebApp/tests/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Back Office E2E Tests", + "extends": "@repo/config/typescript/react-app.json", + "compilerOptions": { + "types": ["node", "@playwright/test"], + "paths": { + "@/*": ["../*"], + "@shared/e2e/fixtures/*": ["../../../shared-webapp/tests/e2e/fixtures/*"], + "@shared/e2e/utils/*": ["../../../shared-webapp/tests/e2e/utils/*"], + "@shared/e2e/auth/*": ["../../../shared-webapp/tests/e2e/auth/*"], + "@shared/e2e/types/*": ["../../../shared-webapp/tests/e2e/types/*"] + } + }, + "include": ["./**/*.ts", "./**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/application/back-office/WebApp/tsconfig.json b/application/back-office/WebApp/tsconfig.json index c5f095bd01..ecd4fb3ef6 100644 --- a/application/back-office/WebApp/tsconfig.json +++ b/application/back-office/WebApp/tsconfig.json @@ -7,5 +7,5 @@ "@/*": ["./*"] } }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "tests/**"] } diff --git a/application/package-lock.json b/application/package-lock.json index fbb7221f43..506aab1355 100644 --- a/application/package-lock.json +++ b/application/package-lock.json @@ -37,9 +37,11 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@faker-js/faker": "8.4.1", "@lingui/cli": "5.1.0", "@lingui/format-po": "5.1.0", "@lingui/swc-plugin": "5.0.1", + "@playwright/test": "1.52.0", "@rsbuild/core": "1.1.10", "@rsbuild/plugin-react": "1.1.0", "@rsbuild/plugin-svgr": "1.0.6", @@ -48,10 +50,12 @@ "@tailwindcss/container-queries": "0.1.1", "@tanstack/router-devtools": "1.90.0", "@tanstack/router-plugin": "1.87.13", + "@types/node": "^22.15.28", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", "openapi-typescript": "7.4.4", "openapi-typescript-helpers": "0.0.15", + "playwright": "1.52.0", "rimraf": "6.0.1", "tailwindcss": "3.4.16", "tailwindcss-animate": "1.0.7", @@ -80,6 +84,11 @@ "@repo/ui": "*" } }, + "End2EndTests": { + "name": "end-2-endtests", + "version": "1.0.0", + "extraneous": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -962,6 +971,23 @@ "node": ">=12" } }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@fontsource/inter": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.1.0.tgz", @@ -1850,6 +1876,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@react-aria/accordion": { "version": "3.0.0-alpha.36", "resolved": "https://registry.npmjs.org/@react-aria/accordion/-/accordion-3.0.0-alpha.36.tgz", @@ -4629,13 +4671,13 @@ } }, "node_modules/@types/node": { - "version": "22.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", - "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "version": "22.15.28", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.28.tgz", + "integrity": "sha512-I0okKVDmyKR281I0UIFV7EWAWRnR0gkuSKob5wVcByyyhr7Px/slhkQapcYX4u00ekzNWaS1gznKZnuzxwo4pw==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~6.21.0" } }, "node_modules/@types/parse-json": { @@ -6940,6 +6982,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -8867,9 +8956,9 @@ } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "devOptional": true, "license": "MIT" }, diff --git a/application/package.json b/application/package.json index 9a63054432..b023fbe56c 100644 --- a/application/package.json +++ b/application/package.json @@ -42,9 +42,11 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", + "@faker-js/faker": "8.4.1", "@lingui/cli": "5.1.0", "@lingui/format-po": "5.1.0", "@lingui/swc-plugin": "5.0.1", + "@playwright/test": "1.52.0", "@rsbuild/core": "1.1.10", "@rsbuild/plugin-react": "1.1.0", "@rsbuild/plugin-svgr": "1.0.6", @@ -53,10 +55,12 @@ "@tailwindcss/container-queries": "0.1.1", "@tanstack/router-devtools": "1.90.0", "@tanstack/router-plugin": "1.87.13", + "@types/node": "^22.15.28", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", "openapi-typescript": "7.4.4", "openapi-typescript-helpers": "0.0.15", + "playwright": "1.52.0", "rimraf": "6.0.1", "tailwindcss": "3.4.16", "tailwindcss-animate": "1.0.7", diff --git a/application/shared-webapp/package.json b/application/shared-webapp/package.json new file mode 100644 index 0000000000..9b4a70e63b --- /dev/null +++ b/application/shared-webapp/package.json @@ -0,0 +1,12 @@ +{ + "name": "@repo/shared-webapp", + "version": "1.0.0", + "private": true, + "exports": { + "./tests/e2e/utils/*": "./tests/e2e/utils/*", + "./tests/e2e/config/*": "./tests/e2e/config/*" + }, + "scripts": { + "test": "playwright test" + } +} diff --git a/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts b/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts new file mode 100644 index 0000000000..c65548ad17 --- /dev/null +++ b/application/shared-webapp/tests/e2e/auth/auth-state-manager.ts @@ -0,0 +1,107 @@ +import { promises as fs } from "node:fs"; +import type { BrowserContext, Page } from "@playwright/test"; +import { + getStorageStatePath, + isAuthenticationStateValid, + loadAuthenticationState, + saveAuthenticationState +} from "@shared/e2e/auth/storage-state"; +import type { UserRole } from "@shared/e2e/types/auth"; + +/** + * Authentication state manager for handling persistence and validation + */ +export class AuthStateManager { + private readonly workerIndex: number; + private readonly selfContainedSystemPrefix?: string; + + constructor(workerIndex: number, selfContainedSystemPrefix?: string) { + this.workerIndex = workerIndex; + this.selfContainedSystemPrefix = selfContainedSystemPrefix; + } + + /** + * Get the storage state file path for a specific role + * @param role User role + * @returns Path to the storage state file + */ + getStateFilePath(role: UserRole): string { + return getStorageStatePath(this.workerIndex, role.toLowerCase(), this.selfContainedSystemPrefix); + } + + /** + * Check if authentication state exists and is valid for a role + * @param role User role + * @returns Promise resolving to true if auth state is valid + */ + async hasValidAuthState(role: UserRole): Promise { + const filePath = this.getStateFilePath(role); + return await isAuthenticationStateValid(filePath); + } + + /** + * Load authentication state for a role into a browser context + * @param context Browser context + * @param role User role + * @returns Promise resolving when state is loaded + */ + async loadAuthState(context: BrowserContext, role: UserRole): Promise { + const filePath = this.getStateFilePath(role); + await loadAuthenticationState(context, filePath); + } + + /** + * Save authentication state for a role from a page + * @param page Playwright page + * @param role User role + * @returns Promise resolving when state is saved + */ + async saveAuthState(page: Page, role: UserRole): Promise { + const filePath = this.getStateFilePath(role); + await saveAuthenticationState(page, filePath); + } + + /** + * Test if the authentication state is still valid by checking URL after navigation + * @param page Playwright page with loaded auth state + * @returns Promise resolving to true if auth is still valid + */ + async validateAuthState(page: Page): Promise { + try { + // Navigate to a protected route + await page.goto("/admin"); + + // If we get redirected to login, auth is invalid + // If we stay on /admin (or any admin route), auth is valid + return !page.url().includes("/login"); + } catch { + // If any error occurs during validation, consider auth invalid + return false; + } + } + + /** + * Clear authentication state for a specific role + * @param role User role + * @returns Promise resolving when state is cleared + */ + async clearAuthState(role: UserRole): Promise { + const filePath = this.getStateFilePath(role); + try { + await fs.unlink(filePath); + } catch { + // File might not exist, which is fine + } + } + +} + +/** + * Create an AuthStateManager instance for the current worker + * @param workerIndex Playwright worker index + * @param selfContainedSystemPrefix Optional system prefix + * @returns AuthStateManager instance + */ +export function createAuthStateManager(workerIndex: number, selfContainedSystemPrefix?: string): AuthStateManager { + return new AuthStateManager(workerIndex, selfContainedSystemPrefix); +} diff --git a/application/shared-webapp/tests/e2e/auth/storage-state.ts b/application/shared-webapp/tests/e2e/auth/storage-state.ts new file mode 100644 index 0000000000..b942cf1a3c --- /dev/null +++ b/application/shared-webapp/tests/e2e/auth/storage-state.ts @@ -0,0 +1,77 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { BrowserContext, Page } from "@playwright/test"; + +/** + * Save authentication state from a page's context to a file + * Only captures cookies as session storage is not needed for PlatformPlatform's token architecture + */ +export async function saveAuthenticationState(page: Page, filePath: string): Promise { + // Ensure directory exists + await ensureDirectoryExists(path.dirname(filePath)); + + // Save storage state (cookies and localStorage) + await page.context().storageState({ path: filePath }); +} + +/** + * Load authentication state from a file into a browser context + */ +export async function loadAuthenticationState(_context: BrowserContext, filePath: string): Promise { + // Storage state is loaded when creating the context, not after + // This function is mainly for validation and future use + const exists = await fileExists(filePath); + if (!exists) { + throw new Error(`Authentication state file not found: ${filePath}`); + } +} + +/** + * Get the storage state file path for a specific worker, role, and system + */ +export function getStorageStatePath(workerIndex: number, userRole: string, selfContainedSystemPrefix?: string): string { + const baseDir = path.join(process.cwd(), "tests/test-results/auth-state"); + const systemPrefix = selfContainedSystemPrefix ?? "default"; + return path.join(baseDir, systemPrefix, `worker-${workerIndex}-${userRole.toLowerCase()}.json`); +} + +/** + * Check if an authentication state file is valid and exists + */ +export async function isAuthenticationStateValid(filePath: string): Promise { + try { + const exists = await fileExists(filePath); + if (!exists) { + return false; + } + + // Check if file is not empty and contains valid JSON + const content = await fs.readFile(filePath, "utf-8"); + const state = JSON.parse(content); + + // Basic validation - should have cookies or origins + return state && (state.cookies || state.origins); + } catch { + return false; + } +} + +// Helper functions +async function ensureDirectoryExists(dirPath: string): Promise { + try { + await fs.mkdir(dirPath, { recursive: true }); + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code !== "EEXIST") { + throw error; + } + } +} + +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} diff --git a/application/shared-webapp/tests/e2e/auth/tenant-provisioning.ts b/application/shared-webapp/tests/e2e/auth/tenant-provisioning.ts new file mode 100644 index 0000000000..e43345c7ad --- /dev/null +++ b/application/shared-webapp/tests/e2e/auth/tenant-provisioning.ts @@ -0,0 +1,88 @@ +import { expect } from "@playwright/test"; +import type { Tenant, User } from "@shared/e2e/types/auth"; + +/** + * Create a tenant with owner, admin, and member users + * @param workerIndex Playwright worker index for unique tenant identification + * @param selfContainedSystemPrefix Optional prefix to separate tenant pools between systems + * @returns Tenant object with user information + */ +export function createTenantWithUsers(workerIndex: number, selfContainedSystemPrefix?: string): Tenant { + const prefix = selfContainedSystemPrefix ? `${selfContainedSystemPrefix}-` : ""; + + // Compact timestamp (YY-MM-DDTHH-MM) + const timestamp = new Date().toISOString().slice(2, 16).replace(/[-:T]/g, ''); + + const tenantName = `${prefix}e2e-tenant-${workerIndex}-${timestamp}`; + + // Generate unique emails for each role with timestamp to avoid conflicts across test runs + const ownerEmailAddress = `e2e-${prefix}-owner@${workerIndex}.${timestamp}.local`; + const adminEmailAddress = `e2e-${prefix}-admin@${workerIndex}.${timestamp}.local`; + const memberEmailAddress = `e2e-${prefix}-member@${workerIndex}.${timestamp}.local`; + + // Create User objects for each role + const owner: User = { + email: ownerEmailAddress, + firstName: "TestOwner", + lastName: `Worker${workerIndex}`, + role: "Owner" + }; + + const admin: User = { + email: adminEmailAddress, + firstName: "TestAdmin", + lastName: `Worker${workerIndex}`, + role: "Admin" + }; + + const member: User = { + email: memberEmailAddress, + firstName: "TestMember", + lastName: `Worker${workerIndex}`, + role: "Member" + }; + + // Return tenant structure - actual signup will be implemented when needed + const tenantId = `tenant-${workerIndex}-${timestamp}`; + + return { + tenantId, + tenantName, + owner, + admin, + member + }; +} + +/** + * Ensure that all tenant users exist in the backend + * This provisions the users through the signup flow if they don't already exist + * @param tenant Tenant object with user information + * @returns Promise that resolves when all users are ensured to exist + */ +export async function ensureTenantUsersExist(tenant: Tenant): Promise { + // Import the authentication utilities dynamically to avoid circular dependencies + const { createAuthStateManager } = await import("../auth/auth-state-manager.js"); + const { completeSignupFlow } = await import("../utils/test-data.js"); + + // Create a temporary browser context for user provisioning + const { chromium } = await import("@playwright/test"); + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + // Create the owner user through centralized signup flow + const { createTestContext } = await import("../utils/test-assertions.js"); + const testContext = createTestContext(page); + await completeSignupFlow(page, expect, tenant.owner, testContext); + + // Save authentication state for reuse + const authManager = createAuthStateManager(0, "account-management"); // Use worker 0 for shared users + await authManager.saveAuthState(page, "Owner"); + } finally { + // Cleanup - always close browser resources + await context.close(); + await browser.close(); + } +} diff --git a/application/shared-webapp/tests/e2e/fixtures/page-auth.ts b/application/shared-webapp/tests/e2e/fixtures/page-auth.ts new file mode 100644 index 0000000000..b7c0a357d2 --- /dev/null +++ b/application/shared-webapp/tests/e2e/fixtures/page-auth.ts @@ -0,0 +1,240 @@ +import { type Browser, type BrowserContext, type Page, test as base, expect } from "@playwright/test"; +import { createAuthStateManager } from "@shared/e2e/auth/auth-state-manager"; +import { getSelfContainedSystemPrefix, getWorkerTenant } from "@shared/e2e/fixtures/worker-auth"; +import type { Tenant, User, UserRole } from "@shared/e2e/types/auth"; +import { completeSignupFlow } from "@shared/e2e/utils/test-data"; +import { createTestContext, assertNoUnexpectedErrors, type TestContext } from "@shared/e2e/utils/test-assertions"; + + +// Extend the global interface to include testTenant +declare global { + interface Window { + testTenant: Tenant; + } +} + +/** + * Role-specific page fixtures for authenticated testing + */ +export interface PageAuthFixtures { + /** + * Authenticated page instance as tenant owner + */ + ownerPage: Page; + + /** + * Authenticated page instance as tenant admin + */ + adminPage: Page; + + /** + * Authenticated page instance as tenant member + */ + memberPage: Page; + + /** + * Anonymous (unauthenticated) page with tenant provisioned + * Useful for testing login/signup flows from a clean state while ensuring users exist + */ + anonymousPage: { page: Page; tenant: Tenant }; +} + +/** + * Perform fresh authentication by going through signup/login flow + */ +async function performFreshAuthentication( + browserContext: BrowserContext, + role: UserRole, + tenant: Tenant | undefined, + authManager: ReturnType +): Promise { + if (!tenant) { + throw new Error("Tenant data is required for fresh authentication"); + } + + // Create a new page for authentication + const page = await browserContext.newPage(); + + // Get the user for this role + const user = getUserForRole(tenant, role); + + // Use the centralized signup flow utility + const testContext = createTestContext(page); + await completeSignupFlow(page, expect, user, testContext); + + // Ensure any modal dialogs are closed by waiting for them to disappear + try { + await page.locator('[role="dialog"]').waitFor({ state: "detached", timeout: 2000 }); + } catch { + // Dialog might not exist or already be closed, which is fine + } + + // Save authentication state + await authManager.saveAuthState(page, role); + + return page; +} + +/** + * Get user for a specific role from tenant data + */ +function getUserForRole(tenant: Tenant, role: UserRole): User { + switch (role) { + case "Owner": + return tenant.owner; + case "Admin": + return tenant.admin; + case "Member": + return tenant.member; + default: + throw new Error(`Unknown role: ${role}`); + } +} + +/** + * Create an authenticated context and page for a specific user role + */ +async function createAuthenticatedContextAndPage( + browser: Browser, + role: UserRole, + workerIndex: number, + selfContainedSystemPrefix?: string, + tenant?: Tenant +): Promise<{ context: BrowserContext; page: Page }> { + const authManager = createAuthStateManager(workerIndex, selfContainedSystemPrefix); + + // Check if we have valid auth state + const hasValidAuth = await authManager.hasValidAuthState(role); + + let context: BrowserContext; + let page: Page; + + if (hasValidAuth) { + // Create context with existing auth state + context = await browser.newContext({ + storageState: authManager.getStateFilePath(role) + }); + page = await context.newPage(); + + // Validate that authentication is still working + const isStillValid = await authManager.validateAuthState(page); + if (!isStillValid) { + // Clear invalid auth state and create fresh session + await authManager.clearAuthState(role); + await context.close(); + + // Create fresh context and perform authentication + context = await browser.newContext(); + page = await performFreshAuthentication(context, role, tenant, authManager); + } + } else { + // Create fresh context and perform authentication + context = await browser.newContext(); + page = await performFreshAuthentication(context, role, tenant, authManager); + } + + return { context, page }; +} + +/** + * Extended test with role-specific authenticated page fixtures + */ +export const test = base.extend({ + ownerPage: async ({ browser }, use, testInfo) => { + const workerIndex = testInfo.parallelIndex; + const systemPrefix = getSelfContainedSystemPrefix(); + + // Get tenant for this worker + const tenant = await getWorkerTenant(workerIndex, systemPrefix); + + // Create authenticated context and page for owner + const { context, page } = await createAuthenticatedContextAndPage( + browser, + "Owner", + workerIndex, + systemPrefix, + tenant + ); + + await use(page); + + // Cleanup - close the context and page + await context.close(); + }, + + adminPage: async ({ browser }, use, testInfo) => { + const workerIndex = testInfo.parallelIndex; + const systemPrefix = getSelfContainedSystemPrefix(); + + // Get tenant for this worker + const tenant = await getWorkerTenant(workerIndex, systemPrefix); + + // Create authenticated context and page for admin + const { context, page } = await createAuthenticatedContextAndPage( + browser, + "Admin", + workerIndex, + systemPrefix, + tenant + ); + + await use(page); + + // Cleanup - close the context and page + await context.close(); + }, + + memberPage: async ({ browser }, use, testInfo) => { + const workerIndex = testInfo.parallelIndex; + const systemPrefix = getSelfContainedSystemPrefix(); + + // Get tenant for this worker + const tenant = await getWorkerTenant(workerIndex, systemPrefix); + + // Create authenticated context and page for member + const { context, page } = await createAuthenticatedContextAndPage( + browser, + "Member", + workerIndex, + systemPrefix, + tenant + ); + + await use(page); + + // Cleanup - close the context and page + await context.close(); + }, + + anonymousPage: async ({ browser }, use, testInfo) => { + const workerIndex = testInfo.parallelIndex; + const systemPrefix = getSelfContainedSystemPrefix(); + + // Get tenant for this worker - ensure users exist for testing existing user flows + const tenant = await getWorkerTenant(workerIndex, systemPrefix, { + workerIndex, + selfContainedSystemPrefix: systemPrefix, + ensureUsersExist: true + }); + + // Create a fresh, unauthenticated context and page + const context = await browser.newContext(); + const page = await context.newPage(); + + await use({ page, tenant }); + + // Cleanup - close the context and page + await context.close(); + } +}); + +// Global afterEach hook to automatically run error checking for ALL tests +base.afterEach(({ page }) => { + if (page) { + // Retrieve the existing context that was created during the test + const existingContext = (page as Page & { __testContext?: TestContext }).__testContext; + if (existingContext) { + assertNoUnexpectedErrors(existingContext); + } + } +}); diff --git a/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts b/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts new file mode 100644 index 0000000000..7560e0d6f8 --- /dev/null +++ b/application/shared-webapp/tests/e2e/fixtures/worker-auth.ts @@ -0,0 +1,63 @@ +import { getStorageStatePath, isAuthenticationStateValid } from "@shared/e2e/auth/storage-state"; +import { createTenantWithUsers, ensureTenantUsersExist } from "@shared/e2e/auth/tenant-provisioning"; +import type { Tenant, TenantProvisioningOptions } from "@shared/e2e/types/auth"; + +/** + * Worker-scoped tenant cache to ensure each worker gets a unique tenant + */ +const workerTenantCache = new Map(); + +/** + * Get or create a tenant for the current worker + * This ensures each Playwright worker gets a unique tenant for parallel execution + * @param workerIndex Playwright worker index from testInfo.parallelIndex + * @param selfContainedSystemPrefix Optional prefix for system separation + * @param options Optional provisioning options + * @returns Promise resolving to a unique tenant for this worker + */ +export async function getWorkerTenant( + workerIndex: number, + selfContainedSystemPrefix?: string, + options?: TenantProvisioningOptions +): Promise { + const cacheKey = `${workerIndex}-${selfContainedSystemPrefix || "default"}`; + + // Return cached tenant if available + if (workerTenantCache.has(cacheKey)) { + const cachedTenant = workerTenantCache.get(cacheKey); + if (cachedTenant) { + // If we need to ensure users exist, do that now + if (options?.ensureUsersExist) { + await ensureTenantUsersExist(cachedTenant); + } + return cachedTenant; + } + } + + // Check if we have valid authentication state for the owner (primary user) + const ownerStorageStatePath = getStorageStatePath(workerIndex, "owner", selfContainedSystemPrefix); + const hasValidAuth = await isAuthenticationStateValid(ownerStorageStatePath); + + // Always create the tenant object structure + const tenant = createTenantWithUsers(workerIndex, selfContainedSystemPrefix); + + if (options?.ensureUsersExist) { + await ensureTenantUsersExist(tenant); + } + + // Cache the tenant for this worker + workerTenantCache.set(cacheKey, tenant); + return tenant; +} + +/** + * Extract the self-contained system prefix from the current working directory or test context + * @returns The self-contained system prefix (e.g., "account-management" or "back-office") + */ +export function getSelfContainedSystemPrefix(): string | undefined { + // Try to extract from current working directory + const cwd = process.cwd(); + const match = cwd.match(/application\/([^\/]+)\/WebApp/); + return match ? match[1] : undefined; +} + diff --git a/application/shared-webapp/tests/e2e/playwright.config.ts b/application/shared-webapp/tests/e2e/playwright.config.ts new file mode 100644 index 0000000000..de52f84962 --- /dev/null +++ b/application/shared-webapp/tests/e2e/playwright.config.ts @@ -0,0 +1,117 @@ +/// +import { defineConfig, devices } from "@playwright/test"; +import { getBaseUrl, isWindows } from "./utils/constants"; + +let workers: number | undefined; +if (process.env.CI) { + workers = 1; // Limit to 1 worker on CI +} else if (isWindows) { + workers = 4; // Limit to 4 workers on Windows to avoid performance issues +} else { + workers = undefined; // On non-Windows systems, use all available CPUs +} + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // Run tests in files in parallel + fullyParallel: true, + + // Fail the build on CI if you accidentally left test.only in the source code. + forbidOnly: !!process.env.CI, + + // Retry on CI only + retries: process.env.CI ? 2 : 0, + + // Opt out of parallel tests on CI. + + workers: workers, + + // Reporter to use. See https://playwright.dev/docs/test-reporters + reporter: process.env.CI ? "github" : [["list"], ["html", { open: "never", outputFolder: "test-results/playwright-report" }]], + + // Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. + use: { + // Base URL to use in actions like `await page.goto('/')`. + // biome-ignore lint/style/useNamingConvention: Using Playwright's required property name + baseURL: getBaseUrl(), + + // Default timeout for actions like click(), fill(), etc. + actionTimeout: 10000, + + // Browser launch options + launchOptions: { + // Slow motion delay controlled by CLI --slow-mo flag + slowMo: process.env.PLAYWRIGHT_SLOW_MO ? Number.parseInt(process.env.PLAYWRIGHT_SLOW_MO) : 0 + }, + + // Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer + trace: "on-first-retry", + // Take screenshot on failure + screenshot: "only-on-failure", + // Record video - always use retain-on-failure for better HTML report compatibility + // Videos will be recorded for failed tests and can be forced on via CLI if needed + video: process.env.PLAYWRIGHT_VIDEO_MODE === "on" ? "on" : "retain-on-failure" + }, + + // Global timeout for each test (double timeout for slow motion) + timeout: (() => { + const baseTimeout = process.env.PLAYWRIGHT_TIMEOUT ? Number.parseInt(process.env.PLAYWRIGHT_TIMEOUT) : 30000; + const isSlowMotion = !!process.env.PLAYWRIGHT_SLOW_MO; + return isSlowMotion ? baseTimeout * 2 : baseTimeout; + })(), + expect: { + timeout: 10000 + }, + + // Output directories - centralized test artifacts + outputDir: "test-results/test-artifacts/", + + // Configure projects for major browsers + projects: [ + // Smoke tests run first (all browsers) - matches @smoke tag in any file + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + grep: /@smoke/ + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + grep: /@smoke/ + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + // Ignore HTTPS errors only for WebKit on Windows, as it's stricter than other browsers + // biome-ignore lint/style/useNamingConvention: + ignoreHTTPSErrors: isWindows + }, + grep: /@smoke/ + }, + + // Comprehensive tests run second (all browsers) - matches @comprehensive tag in any file + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + grepInvert: /@smoke/ + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + grepInvert: /@smoke/ + }, + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + // Ignore HTTPS errors only for WebKit on Windows, as it's stricter than other browsers + // biome-ignore lint/style/useNamingConvention: + ignoreHTTPSErrors: isWindows + }, + grepInvert: /@smoke/ + }, + ] +}); diff --git a/application/shared-webapp/tests/e2e/tsconfig.json b/application/shared-webapp/tests/e2e/tsconfig.json new file mode 100644 index 0000000000..024947fbe0 --- /dev/null +++ b/application/shared-webapp/tests/e2e/tsconfig.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "end-to-end-tests", + "extends": "@repo/config/typescript/node-library.json", + "compilerOptions": { + "baseUrl": ".", + "types": ["node"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "target": "ES2022", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "paths": { + "@shared/e2e/fixtures/*": ["./fixtures/*"], + "@shared/e2e/utils/*": ["./utils/*"], + "@shared/e2e/auth/*": ["./auth/*"], + "@shared/e2e/types/*": ["./types/*"] + } + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "test-results/**"] +} diff --git a/application/shared-webapp/tests/e2e/types/auth.ts b/application/shared-webapp/tests/e2e/types/auth.ts new file mode 100644 index 0000000000..6ad44564d3 --- /dev/null +++ b/application/shared-webapp/tests/e2e/types/auth.ts @@ -0,0 +1,43 @@ + +/** + * Authentication types and interfaces for E2E testing + */ + +/** + * User roles available in the system - must match UserInfoEnv.role from environment.d.ts + */ +export type UserRole = "Owner" | "Admin" | "Member"; + +/** + * User interface containing all user information for E2E testing + */ +export interface User { + email: string; + firstName: string; + lastName: string; + role: UserRole; +} + +/** + * Tenant interface containing all user information for E2E testing + */ +export interface Tenant { + tenantId: string; + tenantName: string; + owner: User; + admin: User; + member: User; +} + + +/** + * Configuration options for tenant provisioning + */ +export interface TenantProvisioningOptions { + workerIndex: number; + selfContainedSystemPrefix?: string; + isolated?: boolean; + ensureUsersExist?: boolean; +} + + diff --git a/application/shared-webapp/tests/e2e/utils/constants.ts b/application/shared-webapp/tests/e2e/utils/constants.ts new file mode 100644 index 0000000000..2d404ca443 --- /dev/null +++ b/application/shared-webapp/tests/e2e/utils/constants.ts @@ -0,0 +1,23 @@ +/// + +/** + * Shared constants for End2End tests + */ + +const DEFAULT_BASE_URL = "https://localhost:9000"; + +export const isWindows = process.platform === "win32"; + +/** + * Get the base URL for tests + */ +export function getBaseUrl(): string { + return process.env.PUBLIC_URL ?? DEFAULT_BASE_URL; +} + +/** + * Check if we're running against localhost + */ +export function isLocalhost(): boolean { + return getBaseUrl() === DEFAULT_BASE_URL; +} diff --git a/application/shared-webapp/tests/e2e/utils/test-assertions.ts b/application/shared-webapp/tests/e2e/utils/test-assertions.ts new file mode 100644 index 0000000000..8db27fd5b7 --- /dev/null +++ b/application/shared-webapp/tests/e2e/utils/test-assertions.ts @@ -0,0 +1,446 @@ +import type { ConsoleMessage, Page } from "@playwright/test"; +import { expect } from "@playwright/test"; + +/** + * Interface for monitoring results - captures ALL errors/messages for strict assertion + */ +export interface MonitoringResults { + consoleMessages: ConsoleMessage[]; + networkErrors: string[]; + expectedStatusCodes: number[]; +} + +/** + * Test context that holds page and monitoring for simplified function calls + */ +export interface TestContext { + page: Page; + monitoring: MonitoringResults; +} + +/** + * Options for expectToastMessage function + */ +interface AssertToastOptions { + expectNetworkError?: boolean; +} + +/** + * Create a test context with page and monitoring for simplified function calls + * @param page Playwright page instance + * @returns Test context with page and monitoring + */ +export function createTestContext(page: Page): TestContext { + const monitoring = startMonitoring(page); + const context = { page, monitoring }; + + // Store context on page object so afterEach hook can access the same instance + (page as Page & { __testContext?: TestContext }).__testContext = context; + + return context; +} + +/** + * Internal function to start monitoring console messages, network errors, and toast messages for a page + * @param page Playwright page instance + * @returns Monitoring results object that will be populated during test execution + */ +function startMonitoring(page: Page): MonitoringResults { + const results: MonitoringResults = { + consoleMessages: [], + networkErrors: [], + expectedStatusCodes: [] + }; + + // Monitor console errors and warnings with filtering for expected messages + page.on("console", (consoleMessage) => { + if (["warning", "error"].includes(consoleMessage.type())) { + const message = consoleMessage.text(); + + // Filter out expected console messages in test environment + const expectedMessages = [ + "Error with Permissions-Policy header: Unrecognized feature: 'web-share'", + "If you do not provide a visible label, you must specify an aria-label or aria-labelledby attribute for accessibility", + "Content-Security-Policy:", + "MouseEvent.mozInputSource is deprecated", + "A PressResponder was rendered without a pressable child", + "WebSocket connection to", // Hot reload/dev server WebSocket connections + "Refused to connect to ws://", // WebSocket CSP violations from dev servers + "Refused to connect to wss://", // Secure WebSocket CSP violations from dev servers + "Loading failed for the