From ccd40656c4a8eb27911326fb4e1dc198753464d8 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 15:22:03 -0600 Subject: [PATCH 1/4] feat: migrate remaining Selenide UI tests to Playwright (#67) Remove all Selenide test infrastructure (18 Java files, 1,664 lines) and consolidate on Playwright as the single E2E framework. All Selenide test scenarios were already covered by existing Playwright specs; two new validation tests (empty fields, long names) were added to fill a gap. Key changes: - Delete Selenide test classes, page objects, utilities, and JDBC helpers - Remove selenide and webdrivermanager dependencies from build.gradle - Remove uiTest Gradle task and excludeTags 'ui' filter - Replace all networkidle waits with domcontentloaded across 14 Playwright spec files (fixes timeout failures caused by WebAuthn endpoints returning 500 on pages that load webauthn JS) - Add profile update validation tests to update-profile.spec.ts - Fix pre-existing Instant/Date type mismatch in TestDataController - Update EmailVerificationEdgeCaseTest to use userRepository instead of deleted DatabaseStateValidator - Update CLAUDE.md and README.md to reflect Playwright as E2E framework Closes #67 --- CLAUDE.md | 7 +- README.md | 9 +- build.gradle | 12 - playwright/src/pages/BasePage.ts | 2 +- playwright/src/pages/EventDetailsPage.ts | 4 +- .../access-control/protected-pages.spec.ts | 32 +- .../tests/auth/email-verification.spec.ts | 10 +- playwright/tests/auth/login.spec.ts | 4 +- playwright/tests/auth/password-reset.spec.ts | 16 +- .../auth/passwordless-registration.spec.ts | 16 +- playwright/tests/auth/registration.spec.ts | 4 +- .../tests/e2e/complete-user-journey.spec.ts | 26 +- playwright/tests/events/browse-events.spec.ts | 12 +- .../tests/events/event-registration.spec.ts | 28 +- playwright/tests/profile/auth-methods.spec.ts | 16 +- .../tests/profile/change-password.spec.ts | 26 +- .../tests/profile/delete-account.spec.ts | 18 +- .../tests/profile/update-profile.spec.ts | 66 ++- .../demo/test/api/TestDataController.java | 3 +- .../spring/demo/user/ui/BaseUiTest.java | 49 -- .../user/ui/CompleteUserJourneyE2ETest.java | 431 ------------------ .../user/ui/SpringUserFrameworkUiTest.java | 98 ---- .../spring/demo/user/ui/data/UiTestData.java | 25 - .../demo/user/ui/page/DeleteAccountPage.java | 132 ------ .../demo/user/ui/page/ForgotPasswordPage.java | 28 -- .../spring/demo/user/ui/page/LoginPage.java | 24 - .../demo/user/ui/page/LoginSuccessPage.java | 13 - .../demo/user/ui/page/RegisterPage.java | 46 -- .../user/ui/page/SuccessRegisterPage.java | 13 - .../ui/page/SuccessResetPasswordPage.java | 13 - .../demo/user/ui/page/UpdatePasswordPage.java | 100 ---- .../demo/user/ui/page/UpdateUserPage.java | 97 ---- .../user/ui/page/VerificationPendingPage.java | 87 ---- .../user/ui/util/DatabaseStateValidator.java | 215 --------- .../ui/util/EmailVerificationSimulator.java | 92 ---- .../spring/user/jdbc/ConnectionManager.java | 37 -- .../spring/user/jdbc/Jdbc.java | 86 ---- .../EmailVerificationEdgeCaseTest.java | 21 +- .../user/ui/page/ForgotPasswordPage.java | 29 -- .../ui/page/SuccessResetPasswordPage.java | 13 - .../resources/application-test.properties | 2 - 41 files changed, 183 insertions(+), 1779 deletions(-) delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/BaseUiTest.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/CompleteUserJourneyE2ETest.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/SpringUserFrameworkUiTest.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/data/UiTestData.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/DeleteAccountPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/ForgotPasswordPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/LoginPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/LoginSuccessPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/RegisterPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/SuccessRegisterPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/SuccessResetPasswordPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/UpdatePasswordPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/UpdateUserPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/VerificationPendingPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/util/DatabaseStateValidator.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/demo/user/ui/util/EmailVerificationSimulator.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/user/ui/page/ForgotPasswordPage.java delete mode 100644 src/test/java/com/digitalsanctuary/spring/user/ui/page/SuccessResetPasswordPage.java diff --git a/CLAUDE.md b/CLAUDE.md index 3822f1a..08a2111 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,12 +18,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Testing ```bash -# Run all tests except UI tests +# Run all tests ./gradlew test -# Run UI tests only -./gradlew uiTest - # Run a specific test class ./gradlew test --tests TestClassName @@ -58,7 +55,7 @@ This is a Spring Boot demo application showcasing the [Spring User Framework](ht 4. **Testing Strategy**: - Unit tests for individual components - Integration tests using `@IntegrationTest` annotation (combines Spring Boot test setup) - - UI tests with Selenide for end-to-end testing + - E2E tests with Playwright (in `playwright/` directory) - API tests using MockMvc for REST endpoints ### Important Conventions diff --git a/README.md b/README.md index 7ef0a52..f36d22d 100644 --- a/README.md +++ b/README.md @@ -193,12 +193,9 @@ This project includes comprehensive testing with multiple approaches: ### Running Tests ```bash -# Run all tests except UI tests +# Run all tests ./gradlew test -# Run UI tests only (requires running application) -./gradlew uiTest - # Run specific test class ./gradlew test --tests UserApiTest @@ -211,7 +208,7 @@ This project includes comprehensive testing with multiple approaches: - **Unit Tests**: Fast tests for individual components - **Integration Tests**: Tests using `@IntegrationTest` with Spring context - **API Tests**: REST endpoint testing with MockMvc -- **UI Tests**: End-to-end testing with Selenide +- **UI Tests**: End-to-end testing with Playwright - **Security Tests**: Authentication and authorization testing ### Test Data @@ -641,7 +638,7 @@ This project supports **Spring Boot DevTools** for live reload and auto-restart. | **Security** | Spring Security 7 | Authentication, authorization, CSRF protection | | **Data** | Spring Data JPA + Hibernate | Object-relational mapping and data access | | **Database** | MariaDB/MySQL | Primary data persistence | -| **Testing** | JUnit 5 + Selenide | Unit, integration, and UI testing | +| **Testing** | JUnit 5 + Playwright | Unit, integration, and UI testing | | **Build** | Gradle | Dependency management and build automation | | **Containers** | Docker + Docker Compose | Development and deployment | diff --git a/build.gradle b/build.gradle index 814cd6f..4bc4358 100644 --- a/build.gradle +++ b/build.gradle @@ -98,8 +98,6 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-security-test' testImplementation 'org.springframework.security:spring-security-test' testImplementation 'com.h2database:h2:2.4.240' - testImplementation 'com.codeborne:selenide:7.15.0' - testImplementation 'io.github.bonigarcia:webdrivermanager:6.3.3' // OAuth2 Testing dependencies testImplementation 'org.wiremock:wiremock-standalone:3.13.2' @@ -110,7 +108,6 @@ dependencies { test { useJUnitPlatform { - excludeTags 'ui' } testLogging { events "PASSED", "FAILED", "SKIPPED" @@ -119,15 +116,6 @@ test { } } -tasks.register('uiTest', Test) { - useJUnitPlatform { - includeTags 'ui' - } - testClassesDirs = sourceSets.test.output.classesDirs - classpath = sourceSets.test.runtimeClasspath - shouldRunAfter test -} - bootRun { // Use Spring Boot DevTool only when we run Gradle bootRun task classpath = sourceSets.main.runtimeClasspath + configurations.developmentOnly diff --git a/playwright/src/pages/BasePage.ts b/playwright/src/pages/BasePage.ts index e308e9b..6020aaf 100644 --- a/playwright/src/pages/BasePage.ts +++ b/playwright/src/pages/BasePage.ts @@ -108,7 +108,7 @@ export abstract class BasePage { * Wait for navigation to complete. */ async waitForNavigation(): Promise { - await this.page.waitForLoadState('networkidle'); + await this.page.waitForLoadState('domcontentloaded'); } /** diff --git a/playwright/src/pages/EventDetailsPage.ts b/playwright/src/pages/EventDetailsPage.ts index 50d81ed..b311d40 100644 --- a/playwright/src/pages/EventDetailsPage.ts +++ b/playwright/src/pages/EventDetailsPage.ts @@ -88,7 +88,7 @@ export class EventDetailsPage extends BasePage { this.page.waitForEvent('load'), dialog.accept(), ]); - await this.page.waitForLoadState('networkidle'); + await this.page.waitForLoadState('domcontentloaded'); return succeeded; } @@ -108,7 +108,7 @@ export class EventDetailsPage extends BasePage { this.page.waitForEvent('load'), dialog.accept(), ]); - await this.page.waitForLoadState('networkidle'); + await this.page.waitForLoadState('domcontentloaded'); return succeeded; } diff --git a/playwright/tests/access-control/protected-pages.spec.ts b/playwright/tests/access-control/protected-pages.spec.ts index c3ac8fa..89c14d5 100644 --- a/playwright/tests/access-control/protected-pages.spec.ts +++ b/playwright/tests/access-control/protected-pages.spec.ts @@ -8,7 +8,7 @@ test.describe('Access Control', () => { }) => { // Try to access protected page without logging in await protectedPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login expect(page.url()).toContain('login'); @@ -28,7 +28,7 @@ test.describe('Access Control', () => { // Access protected page await protectedPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on protected page expect(page.url()).toContain('protected'); @@ -40,7 +40,7 @@ test.describe('Access Control', () => { }) => { // Try to access user profile without logging in await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login expect(page.url()).toContain('login'); @@ -52,7 +52,7 @@ test.describe('Access Control', () => { }) => { // Try to access password change without logging in await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login expect(page.url()).toContain('login'); @@ -64,7 +64,7 @@ test.describe('Access Control', () => { }) => { // Try to access delete account without logging in await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login expect(page.url()).toContain('login'); @@ -76,7 +76,7 @@ test.describe('Access Control', () => { page, }) => { await page.goto('/'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should stay on home page expect(page.url()).not.toContain('login'); @@ -87,7 +87,7 @@ test.describe('Access Control', () => { loginPage, }) => { await loginPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on login page expect(page.url()).toContain('login'); @@ -98,7 +98,7 @@ test.describe('Access Control', () => { registerPage, }) => { await registerPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on registration page expect(page.url()).toContain('register'); @@ -109,7 +109,7 @@ test.describe('Access Control', () => { forgotPasswordPage, }) => { await forgotPasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on forgot password page expect(page.url()).toContain('forgot-password'); @@ -120,7 +120,7 @@ test.describe('Access Control', () => { eventListPage, }) => { await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on events page expect(page.url()).toContain('event'); @@ -130,7 +130,7 @@ test.describe('Access Control', () => { page, }) => { await page.goto('/about.html'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on about page (not redirected) expect(page.url()).toContain('about'); @@ -152,7 +152,7 @@ test.describe('Access Control', () => { // Try to access admin page await adminActionsPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be denied (403 or error page) // With @PreAuthorize, the URL stays the same but shows error page @@ -184,11 +184,11 @@ test.describe('Access Control', () => { // Navigate to multiple protected pages await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); expect(page.url()).toContain('update-user'); await page.goto('/event/my-events.html'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); expect(page.url()).toContain('my-events'); // Should still be logged in @@ -213,11 +213,11 @@ test.describe('Access Control', () => { // Logout await loginPage.logout(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Try to access protected page await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login expect(page.url()).toContain('login'); diff --git a/playwright/tests/auth/email-verification.spec.ts b/playwright/tests/auth/email-verification.spec.ts index f5b8aef..b0de360 100644 --- a/playwright/tests/auth/email-verification.spec.ts +++ b/playwright/tests/auth/email-verification.spec.ts @@ -33,7 +33,7 @@ test.describe('Email Verification', () => { expect(verificationUrl).not.toBeNull(); await page.goto(verificationUrl!); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should redirect to registration complete page expect(page.url()).toContain('registration-complete'); @@ -83,7 +83,7 @@ test.describe('Email Verification', () => { }) => { // Navigate to verification URL with invalid token await page.goto('/user/registrationConfirm?token=invalid-token-12345'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error or redirect to error page const url = page.url(); @@ -121,7 +121,7 @@ test.describe('Email Verification', () => { // Use a fake expired token (any invalid UUID) await page.goto('/user/registrationConfirm?token=expired-invalid-token-12345'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error (same handling as invalid token) const url = page.url(); @@ -168,7 +168,7 @@ test.describe('Email Verification', () => { // Try to use the same token again await page.goto(verificationUrl!); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should either show error or redirect to registration-complete (idempotent behavior) // Both are acceptable - key thing is user stays verified @@ -197,7 +197,7 @@ test.describe('Email Verification', () => { // Navigate to resend verification page await page.goto('/user/request-new-verification-email.html'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Page should load (specific implementation may vary) expect(page.url()).toContain('verification'); diff --git a/playwright/tests/auth/login.spec.ts b/playwright/tests/auth/login.spec.ts index 2415831..a4460d1 100644 --- a/playwright/tests/auth/login.spec.ts +++ b/playwright/tests/auth/login.spec.ts @@ -60,7 +60,7 @@ test.describe('Login', () => { await loginPage.submit(); // Should be redirected to originally requested page or success page - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); }); }); @@ -180,7 +180,7 @@ test.describe('Login', () => { await loginPage.submit(); // Should show error or redirect to verification page - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); const url = page.url(); const hasError = await loginPage.hasError(); diff --git a/playwright/tests/auth/password-reset.spec.ts b/playwright/tests/auth/password-reset.spec.ts index fd0c146..5d3cbcf 100644 --- a/playwright/tests/auth/password-reset.spec.ts +++ b/playwright/tests/auth/password-reset.spec.ts @@ -39,7 +39,7 @@ test.describe('Password Reset', () => { await forgotPasswordPage.requestReset('nonexistent-user-12345@example.com'); // Wait for response - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should either show generic message (for security) or redirect to pending page // Most secure implementations show success even for non-existent emails @@ -81,7 +81,7 @@ test.describe('Password Reset', () => { // Navigate to reset page await page.goto(resetUrl!); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Fill in new password const newPassword = 'NewTest@Pass456!'; @@ -123,7 +123,7 @@ test.describe('Password Reset', () => { await forgotPasswordPage.requestResetAndWait(user.email); const resetUrl = await testApiClient.getPasswordResetUrl(user.email); await page.goto(resetUrl!); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); const newPassword = 'NewTest@Pass789!'; await forgotPasswordChangePage.fillForm(newPassword); @@ -136,7 +136,7 @@ test.describe('Password Reset', () => { await loginPage.goto(); await loginPage.fillCredentials(user.email, originalPassword); await loginPage.submit(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should NOT be logged in (old password should fail) // The login page redirects back to itself on failure @@ -150,7 +150,7 @@ test.describe('Password Reset', () => { }) => { // Navigate to reset page with invalid token await page.goto('/user/changePassword?token=invalid-reset-token-12345'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error const url = page.url(); @@ -189,7 +189,7 @@ test.describe('Password Reset', () => { // Get reset token URL const resetUrl = await testApiClient.getPasswordResetUrl(user.email); await page.goto(resetUrl!); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Try to set a weak password await forgotPasswordChangePage.fillForm('weak'); @@ -229,12 +229,12 @@ test.describe('Password Reset', () => { // Get reset token URL const resetUrl = await testApiClient.getPasswordResetUrl(user.email); await page.goto(resetUrl!); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Try to set mismatched passwords await forgotPasswordChangePage.fillForm('NewTest@Pass123!', 'DifferentPass@456!'); await forgotPasswordChangePage.submit(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error or stay on page (client-side validation) }); diff --git a/playwright/tests/auth/passwordless-registration.spec.ts b/playwright/tests/auth/passwordless-registration.spec.ts index 536484e..8a2bd08 100644 --- a/playwright/tests/auth/passwordless-registration.spec.ts +++ b/playwright/tests/auth/passwordless-registration.spec.ts @@ -6,7 +6,7 @@ test.describe('Passwordless Registration', () => { registerPage, }) => { await registerPage.goto(); - await registerPage.page.waitForLoadState('networkidle'); + await registerPage.page.waitForLoadState('domcontentloaded'); // The toggle should be visible (WebAuthn is supported in Chromium) const toggle = registerPage.page.locator('#registrationModeToggle'); @@ -27,7 +27,7 @@ test.describe('Passwordless Registration', () => { registerPage, }) => { await registerPage.goto(); - await registerPage.page.waitForLoadState('networkidle'); + await registerPage.page.waitForLoadState('domcontentloaded'); // Password fields should be visible initially const passwordFields = registerPage.page.locator('#passwordFields'); @@ -50,7 +50,7 @@ test.describe('Passwordless Registration', () => { registerPage, }) => { await registerPage.goto(); - await registerPage.page.waitForLoadState('networkidle'); + await registerPage.page.waitForLoadState('domcontentloaded'); // Switch to passwordless await registerPage.page.locator('#modePasswordless').click(); @@ -74,7 +74,7 @@ test.describe('Passwordless Registration', () => { registerPage, }) => { await registerPage.goto(); - await registerPage.page.waitForLoadState('networkidle'); + await registerPage.page.waitForLoadState('domcontentloaded'); const passwordBtn = registerPage.page.locator('#modePassword'); const passwordlessBtn = registerPage.page.locator('#modePasswordless'); @@ -94,7 +94,7 @@ test.describe('Passwordless Registration', () => { registerPage, }) => { await registerPage.goto(); - await registerPage.page.waitForLoadState('networkidle'); + await registerPage.page.waitForLoadState('domcontentloaded'); // Switch to passwordless await registerPage.page.locator('#modePasswordless').click(); @@ -112,7 +112,7 @@ test.describe('Passwordless Registration', () => { registerPage, }) => { await registerPage.goto(); - await registerPage.page.waitForLoadState('networkidle'); + await registerPage.page.waitForLoadState('domcontentloaded'); // Password fields should be required initially await expect(registerPage.passwordInput).toHaveAttribute('required', ''); @@ -138,7 +138,7 @@ test.describe('Passwordless Registration', () => { registerPage, }) => { await registerPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Switch to passwordless mode await page.locator('#modePasswordless').click(); @@ -174,7 +174,7 @@ test.describe('Passwordless Registration', () => { registerPage, }) => { await registerPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Stay in password mode (default) await registerPage.fillForm('Test', 'User', 'test-standard@example.com', 'Test@Pass123!'); diff --git a/playwright/tests/auth/registration.spec.ts b/playwright/tests/auth/registration.spec.ts index 3458eb6..0fa0db6 100644 --- a/playwright/tests/auth/registration.spec.ts +++ b/playwright/tests/auth/registration.spec.ts @@ -90,7 +90,7 @@ test.describe('Registration', () => { await registerPage.submit(); // Wait for response - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error or redirect with error parameter const url = page.url(); @@ -140,7 +140,7 @@ test.describe('Registration', () => { await registerPage.submit(); // Wait for validation response - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should either show error or stay on registration page const url = page.url(); diff --git a/playwright/tests/e2e/complete-user-journey.spec.ts b/playwright/tests/e2e/complete-user-journey.spec.ts index 7abb7d3..12ca9c0 100644 --- a/playwright/tests/e2e/complete-user-journey.spec.ts +++ b/playwright/tests/e2e/complete-user-journey.spec.ts @@ -71,7 +71,7 @@ test.describe('Complete User Journey', () => { // ========================================== await test.step('Update profile', async () => { await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Verify current values expect(await updateUserPage.getFirstName()).toBe(user.firstName); @@ -94,7 +94,7 @@ test.describe('Complete User Journey', () => { // ========================================== await test.step('Change password', async () => { await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Change password await updatePasswordPage.changePasswordAndWait(user.password, newPassword); @@ -114,12 +114,12 @@ test.describe('Complete User Journey', () => { let registeredForEvent = false; await test.step('Register for event', async () => { await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); const eventCount = await eventListPage.getEventCount(); if (eventCount > 0) { await eventListPage.clickEventByIndex(0); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); if (await eventDetailsPage.canRegister()) { registeredForEvent = await eventDetailsPage.register(); @@ -127,7 +127,7 @@ test.describe('Complete User Journey', () => { // Server-side rendered page may show stale state under concurrent load; // if the API confirmed success but the page hasn't caught up, reload once. if (!await eventDetailsPage.canUnregister()) { - await page.reload({ waitUntil: 'networkidle' }); + await page.reload({ waitUntil: 'domcontentloaded' }); } expect(await eventDetailsPage.canUnregister()).toBe(true); } @@ -152,7 +152,7 @@ test.describe('Complete User Journey', () => { // ========================================== await test.step('Delete account', async () => { await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Delete account await deleteAccountPage.deleteAccountAndWait(); @@ -168,7 +168,7 @@ test.describe('Complete User Journey', () => { await loginPage.goto(); await loginPage.fillCredentials(user.email, newPassword); await loginPage.submit(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error or be redirected expect(await loginPage.hasError() || !await loginPage.isLoggedIn()).toBe(true); @@ -191,7 +191,7 @@ test.describe('Complete User Journey', () => { ); await registerPage.acceptTerms(); await registerPage.submit(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should stay on registration page or show error expect(page.url()).toContain('register'); @@ -220,7 +220,7 @@ test.describe('Complete User Journey', () => { await test.step('Access protected page without auth redirects to login', async () => { await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should redirect to login expect(page.url()).toContain('login'); @@ -229,14 +229,14 @@ test.describe('Complete User Journey', () => { await test.step('Login and verify access to protected page', async () => { await loginPage.fillCredentials(user.email, user.password); await loginPage.submit(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be logged in expect(await loginPage.isLoggedIn()).toBe(true); // Access protected page await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on protected page expect(page.url()).toContain('update-user'); @@ -276,7 +276,7 @@ test.describe('Complete User Journey', () => { expect(resetUrl).not.toBeNull(); await page.goto(resetUrl!); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); await forgotPasswordChangePage.fillForm(newPassword); await forgotPasswordChangePage.submit(); @@ -295,7 +295,7 @@ test.describe('Complete User Journey', () => { await loginPage.goto(); await loginPage.fillCredentials(user.email, originalPassword); await loginPage.submit(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); expect(await loginPage.hasError()).toBe(true); }); diff --git a/playwright/tests/events/browse-events.spec.ts b/playwright/tests/events/browse-events.spec.ts index 4b25e0a..209069d 100644 --- a/playwright/tests/events/browse-events.spec.ts +++ b/playwright/tests/events/browse-events.spec.ts @@ -8,7 +8,7 @@ test.describe('Browse Events', () => { }) => { // Navigate to events page without logging in await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should display the events page expect(page.url()).toContain('event'); @@ -26,7 +26,7 @@ test.describe('Browse Events', () => { }) => { // Navigate to events page await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // If there are events, verify they have expected content const eventCount = await eventListPage.getEventCount(); @@ -49,13 +49,13 @@ test.describe('Browse Events', () => { }) => { // Navigate to events page await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // If there are events, click on one const eventCount = await eventListPage.getEventCount(); if (eventCount > 0) { await eventListPage.clickEventByIndex(0); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on event details page expect(page.url()).toContain('details'); @@ -71,13 +71,13 @@ test.describe('Browse Events', () => { }) => { // Navigate to events page await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // If there are events, go to details const eventCount = await eventListPage.getEventCount(); if (eventCount > 0) { await eventListPage.clickEventByIndex(0); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show login prompt (not register/unregister buttons) const hasLoginPrompt = await eventDetailsPage.hasLoginPrompt(); diff --git a/playwright/tests/events/event-registration.spec.ts b/playwright/tests/events/event-registration.spec.ts index 3b25876..861fd61 100644 --- a/playwright/tests/events/event-registration.spec.ts +++ b/playwright/tests/events/event-registration.spec.ts @@ -17,18 +17,18 @@ test.describe('Event Registration', () => { // Navigate to events page await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // If there are events, register for one const eventCount = await eventListPage.getEventCount(); if (eventCount > 0) { await eventListPage.clickEventByIndex(0); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Check if we can register if (await eventDetailsPage.canRegister()) { await eventDetailsPage.register(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Wait for page to update and show unregister button await page.locator('button:has-text("Unregister")').waitFor({ state: 'visible', timeout: 5000 }); @@ -54,13 +54,13 @@ test.describe('Event Registration', () => { // Navigate to events page await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // If there are events, check details page const eventCount = await eventListPage.getEventCount(); if (eventCount > 0) { await eventListPage.clickEventByIndex(0); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Either register or unregister button should be visible const canRegister = await eventDetailsPage.canRegister(); @@ -87,24 +87,24 @@ test.describe('Event Registration', () => { // Navigate to events page await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // If there are events const eventCount = await eventListPage.getEventCount(); if (eventCount > 0) { await eventListPage.clickEventByIndex(0); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // If not registered, register first if (await eventDetailsPage.canRegister()) { await eventDetailsPage.register(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); } // Now unregister if (await eventDetailsPage.canUnregister()) { await eventDetailsPage.unregister(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // After unregistering, should show register button expect(await eventDetailsPage.canRegister()).toBe(true); @@ -127,7 +127,7 @@ test.describe('Event Registration', () => { // Navigate to my events page await page.goto('/event/my-events.html'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be on my events page (not redirected to login) expect(page.url()).toContain('my-events'); @@ -138,7 +138,7 @@ test.describe('Event Registration', () => { }) => { // Access my events page without logging in await page.goto('/event/my-events.html'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Page should load (it's public but shows personalized content when logged in) expect(page.url()).toContain('my-events'); @@ -156,17 +156,17 @@ test.describe('Event Registration', () => { }) => { // Navigate to events page await eventListPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // If there are events, go to details and back const eventCount = await eventListPage.getEventCount(); if (eventCount > 0) { await eventListPage.clickEventByIndex(0); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Go back to events list await eventDetailsPage.goBackToEvents(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); expect(page.url()).toContain('event'); } diff --git a/playwright/tests/profile/auth-methods.spec.ts b/playwright/tests/profile/auth-methods.spec.ts index 214dc42..10a01e6 100644 --- a/playwright/tests/profile/auth-methods.spec.ts +++ b/playwright/tests/profile/auth-methods.spec.ts @@ -13,7 +13,7 @@ test.describe('Authentication Methods', () => { await createAndLoginUser(page, testApiClient, user); await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Auth methods section should become visible after JS loads const authSection = page.locator('#auth-methods-section'); @@ -32,7 +32,7 @@ test.describe('Authentication Methods', () => { await createAndLoginUser(page, testApiClient, user); await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Wait for auth methods to load const badgesContainer = page.locator('#auth-method-badges'); @@ -54,7 +54,7 @@ test.describe('Authentication Methods', () => { await createAndLoginUser(page, testApiClient, user); await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Wait for auth methods section to load const authSection = page.locator('#auth-methods-section'); @@ -76,7 +76,7 @@ test.describe('Authentication Methods', () => { await createAndLoginUser(page, testApiClient, user); await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Wait for auth methods section to load const authSection = page.locator('#auth-methods-section'); @@ -100,7 +100,7 @@ test.describe('Authentication Methods', () => { await createAndLoginUser(page, testApiClient, user); await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Passkey section should be visible const passkeySection = page.locator('#passkey-section'); @@ -126,7 +126,7 @@ test.describe('Authentication Methods', () => { await createAndLoginUser(page, testApiClient, user); await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Wait for passkeys to load const passkeysList = page.locator('#passkeys-list'); @@ -150,7 +150,7 @@ test.describe('Authentication Methods', () => { await createAndLoginUser(page, testApiClient, user); await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Current password section should be visible const currentPasswordSection = page.locator('#currentPasswordSection'); @@ -179,7 +179,7 @@ test.describe('Authentication Methods', () => { await createAndLoginUser(page, testApiClient, user); await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Wait for auth methods to load so link text updates const authSection = page.locator('#auth-methods-section'); diff --git a/playwright/tests/profile/change-password.spec.ts b/playwright/tests/profile/change-password.spec.ts index ac0f438..fc9ae5e 100644 --- a/playwright/tests/profile/change-password.spec.ts +++ b/playwright/tests/profile/change-password.spec.ts @@ -18,7 +18,7 @@ test.describe('Change Password', () => { // Navigate to change password page await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Change password const newPassword = 'NewTest@Pass456!'; @@ -53,7 +53,7 @@ test.describe('Change Password', () => { // Navigate to change password page await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Change password const newPassword = 'NewTest@Pass789!'; @@ -89,7 +89,7 @@ test.describe('Change Password', () => { // Navigate to change password page await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Try to change password with wrong current password // Listen for the server response to verify error handling @@ -133,11 +133,11 @@ test.describe('Change Password', () => { // Navigate to change password page await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Try to change to a weak password await updatePasswordPage.changePassword(user.password, 'weak'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error or validation message const url = page.url(); @@ -158,7 +158,7 @@ test.describe('Change Password', () => { // Navigate to change password page await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Try to change with mismatched passwords await updatePasswordPage.fillForm( @@ -167,7 +167,7 @@ test.describe('Change Password', () => { 'DifferentPass@456!' ); await updatePasswordPage.submit(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error or validation message (client-side validation) }); @@ -186,11 +186,11 @@ test.describe('Change Password', () => { // Navigate to change password page await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Try to change to the same password await updatePasswordPage.changePassword(user.password, user.password); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // May or may not be rejected depending on policy }); @@ -213,7 +213,7 @@ test.describe('Change Password', () => { // Navigate to change password page await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Change password const newPassword = 'NewTest@Pass111!'; @@ -221,10 +221,10 @@ test.describe('Change Password', () => { // Now try to change back to original password await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); await updatePasswordPage.changePassword(newPassword, originalPassword); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should reject due to password history (if enabled) // Behavior depends on configuration @@ -238,7 +238,7 @@ test.describe('Change Password', () => { }) => { // Try to access password change page without logging in await updatePasswordPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login expect(page.url()).toContain('login'); diff --git a/playwright/tests/profile/delete-account.spec.ts b/playwright/tests/profile/delete-account.spec.ts index 2da73c5..13c2fdf 100644 --- a/playwright/tests/profile/delete-account.spec.ts +++ b/playwright/tests/profile/delete-account.spec.ts @@ -17,7 +17,7 @@ test.describe('Delete Account', () => { // Navigate to delete account page await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Delete account await deleteAccountPage.deleteAccountAndWait(); @@ -46,7 +46,7 @@ test.describe('Delete Account', () => { // Navigate to delete account page await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Delete account and wait for success message await deleteAccountPage.deleteAccountAndWait(); @@ -54,7 +54,7 @@ test.describe('Delete Account', () => { // The page shows success message but session is invalidated server-side. // Navigate to a protected page to verify we're logged out. await page.goto('/user/update-user.html'); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login (session was invalidated) expect(page.url()).toContain('login'); @@ -77,7 +77,7 @@ test.describe('Delete Account', () => { // Navigate to delete account page await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Delete account await deleteAccountPage.deleteAccountAndWait(); @@ -86,7 +86,7 @@ test.describe('Delete Account', () => { await loginPage.goto(); await loginPage.fillCredentials(user.email, password); await loginPage.submit(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should show error const hasError = await loginPage.hasError(); @@ -111,7 +111,7 @@ test.describe('Delete Account', () => { // Navigate to delete account page await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Click delete button await deleteAccountPage.clickDelete(); @@ -135,7 +135,7 @@ test.describe('Delete Account', () => { // Navigate to delete account page await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Open modal and cancel await deleteAccountPage.clickDelete(); @@ -165,7 +165,7 @@ test.describe('Delete Account', () => { // Navigate to delete account page await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Open modal await deleteAccountPage.clickDelete(); @@ -190,7 +190,7 @@ test.describe('Delete Account', () => { }) => { // Try to access delete account page without logging in await deleteAccountPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login expect(page.url()).toContain('login'); diff --git a/playwright/tests/profile/update-profile.spec.ts b/playwright/tests/profile/update-profile.spec.ts index 0a66084..401a062 100644 --- a/playwright/tests/profile/update-profile.spec.ts +++ b/playwright/tests/profile/update-profile.spec.ts @@ -16,7 +16,7 @@ test.describe('Update Profile', () => { // Navigate to update user page await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Update first name const newFirstName = 'UpdatedFirst'; @@ -44,7 +44,7 @@ test.describe('Update Profile', () => { // Navigate to update user page await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Update last name const newLastName = 'UpdatedLast'; @@ -72,7 +72,7 @@ test.describe('Update Profile', () => { // Navigate to update user page await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Update both names const newFirstName = 'NewFirst'; @@ -108,7 +108,7 @@ test.describe('Update Profile', () => { // Navigate to update user page await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Verify current values are shown const currentFirst = await updateUserPage.getFirstName(); @@ -134,7 +134,7 @@ test.describe('Update Profile', () => { // Navigate to update user page await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Navigate to change password await updateUserPage.goToChangePassword(); @@ -156,7 +156,7 @@ test.describe('Update Profile', () => { // Navigate to update user page await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Navigate to delete account await updateUserPage.goToDeleteAccount(); @@ -165,6 +165,58 @@ test.describe('Update Profile', () => { }); }); + test.describe('Validation', () => { + test('should handle empty field submission gracefully', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('update-empty'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + + await updateUserPage.goto(); + await page.waitForLoadState('domcontentloaded'); + + // Clear both fields and attempt to submit + await updateUserPage.firstNameInput.fill(''); + await updateUserPage.lastNameInput.fill(''); + await updateUserPage.submit(); + + // HTML5 required validation should prevent submission + // or server should return an error - verify no unhandled crash + // The form should still be on the update page + expect(page.url()).toContain('update-user'); + }); + + test('should handle excessively long names gracefully', async ({ + page, + updateUserPage, + testApiClient, + cleanupEmails, + }) => { + const user = generateTestUser('update-long'); + cleanupEmails.push(user.email); + + await createAndLoginUser(page, testApiClient, user); + + await updateUserPage.goto(); + await page.waitForLoadState('domcontentloaded'); + + // Submit with very long names (300+ chars) + const longName = 'A'.repeat(300); + await updateUserPage.updateProfile(longName, longName); + + // Wait for server response + await page.waitForLoadState('domcontentloaded'); + + // App should handle gracefully - either succeed or show error, not crash + expect(page.url()).toContain('update-user'); + }); + }); + test.describe('Access Control', () => { test('should require authentication to access update page', async ({ page, @@ -172,7 +224,7 @@ test.describe('Update Profile', () => { }) => { // Try to access update page without logging in await updateUserPage.goto(); - await page.waitForLoadState('networkidle'); + await page.waitForLoadState('domcontentloaded'); // Should be redirected to login expect(page.url()).toContain('login'); diff --git a/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java b/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java index 7e5c5da..1aebd08 100644 --- a/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java +++ b/src/main/java/com/digitalsanctuary/spring/demo/test/api/TestDataController.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.util.Collections; import java.time.Instant; +import java.util.Date; import java.util.HashMap; import java.util.Map; import org.springframework.context.annotation.Profile; @@ -193,7 +194,7 @@ public ResponseEntity> createTestUser(@RequestBody CreateUse user.setEnabled(request.enabled() != null ? request.enabled() : true); user.setLocked(false); user.setFailedLoginAttempts(0); - user.setRegistrationDate(Instant.now()); + user.setRegistrationDate(Date.from(Instant.now())); // Assign default role Role userRole = roleRepository.findByName("ROLE_USER"); diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/BaseUiTest.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/BaseUiTest.java deleted file mode 100644 index c309bf1..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/BaseUiTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui; - -import com.codeborne.selenide.Configuration; -import com.codeborne.selenide.Selenide; -import io.github.bonigarcia.wdm.WebDriverManager; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; - -public abstract class BaseUiTest { - private Driver driver; - - public enum Driver { - CHROME("chrome"), OPERA("opera"), FIREFOX("firefox"), EDGE("edge"); - - private final String browser; - - Driver(String browser) { - this.browser = browser; - } - } - - public void setUp() { - switch (this.driver) { - case CHROME -> WebDriverManager.chromedriver().setup(); - case OPERA -> WebDriverManager.operadriver().setup(); - case FIREFOX -> WebDriverManager.firefoxdriver().setup(); - case EDGE -> WebDriverManager.edgedriver().setup(); - } - Configuration.browser = driver.browser; - Configuration.browserSize = "2560x1440"; - Configuration.webdriverLogsEnabled = true; - Configuration.headless = false; - } - - @BeforeEach - public void init() { - setUp(); - } - - @AfterEach - public void tearDown() { - Selenide.closeWebDriver(); - } - - void setDriver(Driver driver) { - this.driver = driver; - } - -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/CompleteUserJourneyE2ETest.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/CompleteUserJourneyE2ETest.java deleted file mode 100644 index b97aeff..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/CompleteUserJourneyE2ETest.java +++ /dev/null @@ -1,431 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui; - -import com.codeborne.selenide.Configuration; -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.Screenshots; -import com.digitalsanctuary.spring.demo.user.ui.page.*; -import com.digitalsanctuary.spring.demo.user.ui.util.DatabaseStateValidator; -import com.digitalsanctuary.spring.demo.user.ui.util.EmailVerificationSimulator; -import com.digitalsanctuary.spring.user.dto.UserDto; -import com.digitalsanctuary.spring.user.jdbc.Jdbc; -import org.junit.jupiter.api.*; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -import java.io.File; -import java.io.IOException; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Complete User Journey E2E Test as specified in Task 4.1 of TEST-IMPROVEMENT-PLAN.md - * - * Tests the complete user lifecycle from registration to deletion including: - * - User Registration with form validation - * - Email Verification simulation - * - Profile Management (update user details) - * - Password Change functionality - * - Password Reset flow - * - Account Deletion with confirmation - * - * Features: - * - Multi-browser testing (Chrome, Firefox, Edge) - * - Database state validation at each step - * - Screenshot capture on failures - * - Error scenario testing - * - Async operation handling - * - CSRF protection verification - */ -@Tag("ui") -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -@ActiveProfiles("test") -@DisplayName("Complete User Journey E2E Tests") -public class CompleteUserJourneyE2ETest extends BaseUiTest { - - private static final String BASE_URI = "http://localhost:8080"; - private static final DateTimeFormatter TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); - - @Value("${test.browser}") - private String defaultBrowser; - - // Test data - private UserDto testUser; - private String testEmail; - private String originalPassword; - private String newPassword; - - @BeforeAll - public void setupBrowser() { - // Configure screenshot settings - Configuration.reportsFolder = "build/reports/selenide-screenshots"; - Configuration.screenshots = true; - Configuration.savePageSource = true; - } - - @BeforeEach - public void setupTestData() { - // Generate unique test data for each test - String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT); - testEmail = "journey.test." + timestamp + "@example.com"; - originalPassword = "OriginalPass123!"; - newPassword = "NewPassword456!"; - - testUser = new UserDto(); - testUser.setFirstName("Journey"); - testUser.setLastName("Tester" + timestamp); - testUser.setEmail(testEmail); - testUser.setPassword(originalPassword); - testUser.setMatchingPassword(originalPassword); - } - - @AfterEach - public void cleanup() { - try { - // Clean up test user data - Jdbc.deleteTestUser(testUser); - } catch (Exception e) { - // Ignore cleanup errors for deleted accounts - } - } - - @Nested - @DisplayName("Complete User Journey Tests") - class CompleteUserJourneyTests { - - @ParameterizedTest - @EnumSource(value = Driver.class, names = {"CHROME", "FIREFOX", "EDGE"}) - @DisplayName("Complete Happy Path User Journey") - void completeHappyPathUserJourney(Driver browser) throws IOException { - setDriver(browser); - setUp(); - - try { - // Step 1: User Registration - performUserRegistration(); - - // Step 2: Email Verification - performEmailVerification(); - - // Step 3: Profile Management - performProfileUpdate(); - - // Step 4: Password Change - performPasswordChange(); - - // Step 5: Password Reset Flow (testing the flow) - performPasswordResetFlow(); - - // Step 6: Account Deletion - performAccountDeletion(); - - } catch (Exception e) { - captureScreenshotOnFailure(browser.name() + "_CompleteJourney"); - throw e; - } - } - - @Test - @DisplayName("User Journey with Error Scenarios") - void userJourneyWithErrorScenarios() throws IOException { - setDriver(Driver.CHROME); - setUp(); - - try { - // Register user first - performUserRegistration(); - performEmailVerification(); - - // Test error scenarios - testProfileUpdateErrors(); - testPasswordChangeErrors(); - testAccountDeletionErrors(); - - } catch (Exception e) { - captureScreenshotOnFailure("ErrorScenarios"); - throw e; - } - } - - @Test - @DisplayName("Email Verification Edge Cases") - void emailVerificationEdgeCases() throws IOException { - setDriver(Driver.CHROME); - setUp(); - - try { - // Register user - performUserRegistration(); - - // Test invalid verification token - testInvalidEmailVerification(); - - // Test valid verification - performEmailVerification(); - - } catch (Exception e) { - captureScreenshotOnFailure("EmailVerificationEdgeCases"); - throw e; - } - } - - @Test - @DisplayName("Concurrent User Operations") - void concurrentUserOperations() throws IOException { - setDriver(Driver.CHROME); - setUp(); - - try { - // Test concurrent registration attempts - testConcurrentRegistration(); - - } catch (Exception e) { - captureScreenshotOnFailure("ConcurrentOperations"); - throw e; - } - } - } - - // Helper methods for test steps - - private void performUserRegistration() { - RegisterPage registerPage = new RegisterPage(BASE_URI + "/user/register.html"); - - // Fill and submit registration form - SuccessRegisterPage successPage = registerPage.signUp( - testUser.getFirstName(), - testUser.getLastName(), - testUser.getEmail(), - testUser.getPassword(), - testUser.getMatchingPassword() - ); - - // Verify success message - String successMessage = successPage.message(); - assertTrue(successMessage.contains("Thank you for registering"), - "Registration success message not displayed"); - - // Verify database state - DatabaseStateValidator.validateUserRegistered(testEmail, - testUser.getFirstName(), testUser.getLastName()); - - // User should not be enabled yet (pending email verification) - assertFalse(DatabaseStateValidator.isUserEnabled(testEmail), - "User should not be enabled before email verification"); - } - - private void performEmailVerification() { - // Simulate email verification - assertTrue(EmailVerificationSimulator.hasVerificationToken(testEmail), - "Verification token should exist"); - - String verificationToken = EmailVerificationSimulator.getVerificationToken(testEmail); - assertNotNull(verificationToken, "Verification token should not be null"); - - // Simulate clicking verification link - String verificationUrl = EmailVerificationSimulator.generateVerificationUrl(testEmail); - Selenide.open(verificationUrl); - - // Verify database state after verification - DatabaseStateValidator.validateEmailVerified(testEmail); - } - - private void performProfileUpdate() { - // Navigate to update profile page - UpdateUserPage updatePage = new UpdateUserPage(BASE_URI + "/user/update-user.html"); - updatePage.waitForPageLoad(); - - // Verify current profile data - assertEquals(testUser.getFirstName(), updatePage.getFirstName()); - assertEquals(testUser.getLastName(), updatePage.getLastName()); - - // Update profile - String newFirstName = "Updated" + testUser.getFirstName(); - String newLastName = "Updated" + testUser.getLastName(); - - updatePage.updateProfile(newFirstName, newLastName); - - // Verify success message - assertTrue(updatePage.isUpdateSuccessful(), - "Profile update should be successful"); - - // Verify database state - DatabaseStateValidator.validateProfileUpdated(testEmail, newFirstName, newLastName); - - // Update test data for subsequent steps - testUser.setFirstName(newFirstName); - testUser.setLastName(newLastName); - } - - private void performPasswordChange() { - UpdateUserPage updateUserPage = new UpdateUserPage(BASE_URI + "/user/update-user.html"); - UpdatePasswordPage passwordPage = updateUserPage.goToChangePassword(); - passwordPage.waitForPageLoad(); - - // Change password - passwordPage.updatePassword(originalPassword, newPassword); - - // Verify success - assertTrue(passwordPage.isPasswordUpdateSuccessful(), - "Password change should be successful"); - - // Test login with new password - LoginPage loginPage = new LoginPage(BASE_URI + "/user/login.html"); - LoginSuccessPage loginSuccess = loginPage.signIn(testEmail, newPassword); - - String welcomeMessage = loginSuccess.welcomeMessage(); - assertTrue(welcomeMessage.contains(testUser.getFirstName()), - "Login with new password should be successful"); - } - - private void performPasswordResetFlow() { - // Logout first - Selenide.open(BASE_URI + "/user/logout"); - - // Request password reset - ForgotPasswordPage forgotPasswordPage = new ForgotPasswordPage(BASE_URI + "/user/forgot-password.html"); - SuccessResetPasswordPage resetPage = forgotPasswordPage.fillEmail(testEmail).clickSubmitBtn(); - - String resetMessage = resetPage.message(); - assertTrue(resetMessage.contains("password reset email"), - "Password reset request should be successful"); - - // Verify password reset token was created - assertTrue(DatabaseStateValidator.hasPasswordResetToken(testEmail), - "Password reset token should be created"); - } - - private void performAccountDeletion() { - // Login first - LoginPage loginPage = new LoginPage(BASE_URI + "/user/login.html"); - loginPage.signIn(testEmail, newPassword); - - // Navigate to delete account page - DeleteAccountPage deletePage = new DeleteAccountPage(BASE_URI + "/user/delete-account.html"); - deletePage.waitForPageLoad(); - - // Start deletion process - deletePage.startDeletion(); - assertTrue(deletePage.isConfirmationModalVisible(), - "Confirmation modal should be visible"); - - // Complete deletion - deletePage.confirmDeletion(); - - // Verify success - assertTrue(deletePage.isDeletionSuccessful(), - "Account deletion should be successful"); - - // Verify database state - DatabaseStateValidator.validateAccountDeleted(testEmail); - - // Verify cannot login after deletion - LoginPage loginAfterDeletion = new LoginPage(BASE_URI + "/user/login.html"); - loginAfterDeletion.signIn(testEmail, newPassword); - - // Should remain on login page or show error - String currentUrl = Selenide.webdriver().driver().url(); - assertTrue(currentUrl.contains("login"), - "Should not be able to login after account deletion"); - } - - // Error scenario test methods - - private void testProfileUpdateErrors() { - UpdateUserPage updatePage = new UpdateUserPage(BASE_URI + "/user/update-user.html"); - - // Test with empty fields - updatePage.updateProfile("", ""); - // Should show validation errors or remain on same page - - // Test with very long names - String veryLongName = "a".repeat(300); - updatePage.updateProfile(veryLongName, veryLongName); - // Should handle gracefully - } - - private void testPasswordChangeErrors() { - UpdateUserPage updateUserPage = new UpdateUserPage(BASE_URI + "/user/update-user.html"); - UpdatePasswordPage passwordPage = updateUserPage.goToChangePassword(); - - // Test with wrong current password - passwordPage.updatePassword("WrongPassword", "NewPassword123!"); - assertFalse(passwordPage.isPasswordUpdateSuccessful(), - "Password change should fail with wrong current password"); - - // Test with mismatched new passwords - passwordPage.clearAllFields(); - passwordPage.updatePassword(newPassword, "NewPassword123!", "DifferentPassword"); - assertTrue(passwordPage.hasPasswordMismatchError(), - "Should show password mismatch error"); - } - - private void testAccountDeletionErrors() { - DeleteAccountPage deletePage = new DeleteAccountPage(BASE_URI + "/user/delete-account.html"); - - // Start deletion - deletePage.startDeletion(); - - // Test with wrong confirmation text - deletePage.confirmDeletion("WRONG"); - assertTrue(deletePage.hasError(), - "Should show error for wrong confirmation text"); - - // Cancel and try again - deletePage.cancelDeletion(); - assertFalse(deletePage.isConfirmationModalVisible(), - "Modal should be hidden after cancel"); - } - - private void testInvalidEmailVerification() { - String invalidUrl = EmailVerificationSimulator.generateInvalidVerificationUrl(); - Selenide.open(invalidUrl); - - // Should show error or redirect to appropriate page - String currentUrl = Selenide.webdriver().driver().url(); - // Verification should fail gracefully - } - - private void testConcurrentRegistration() { - // This test simulates attempting to register the same email twice - // First registration should succeed, second should fail - - RegisterPage registerPage1 = new RegisterPage(BASE_URI + "/user/register.html"); - registerPage1.signUp( - testUser.getFirstName(), - testUser.getLastName(), - testEmail, - testUser.getPassword(), - testUser.getMatchingPassword() - ); - - // Attempt second registration with same email - RegisterPage registerPage2 = new RegisterPage(BASE_URI + "/user/register.html"); - registerPage2.signUp( - "Different", - "User", - testEmail, // Same email - "DifferentPassword123!", - "DifferentPassword123!" - ); - - // Should show error message - String errorMessage = registerPage2.accountExistErrorMessage(); - assertTrue(errorMessage.contains("already exists"), - "Should show account exists error for duplicate email"); - } - - private void captureScreenshotOnFailure(String testName) throws IOException { - String timestamp = LocalDateTime.now().format(TIMESTAMP_FORMAT); - String filename = testName + "_" + timestamp; - File screenshot = Screenshots.takeScreenShotAsFile(); - if (screenshot != null) { - System.out.println("Screenshot captured: " + screenshot.getAbsolutePath()); - } - } -} \ No newline at end of file diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/SpringUserFrameworkUiTest.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/SpringUserFrameworkUiTest.java deleted file mode 100644 index 8fdb63f..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/SpringUserFrameworkUiTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui; - - -import static com.digitalsanctuary.spring.demo.user.ui.data.UiTestData.ACCOUNT_EXIST_ERROR_MESSAGE; -import static com.digitalsanctuary.spring.demo.user.ui.data.UiTestData.SUCCESS_RESET_PASSWORD_MESSAGE; -import static com.digitalsanctuary.spring.demo.user.ui.data.UiTestData.SUCCESS_SING_UP_MESSAGE; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import com.digitalsanctuary.spring.demo.user.ui.data.UiTestData; -import com.digitalsanctuary.spring.demo.user.ui.page.ForgotPasswordPage; -import com.digitalsanctuary.spring.demo.user.ui.page.LoginPage; -import com.digitalsanctuary.spring.demo.user.ui.page.LoginSuccessPage; -import com.digitalsanctuary.spring.demo.user.ui.page.RegisterPage; -import com.digitalsanctuary.spring.demo.user.ui.page.SuccessRegisterPage; -import com.digitalsanctuary.spring.demo.user.ui.page.SuccessResetPasswordPage; -import com.digitalsanctuary.spring.user.dto.UserDto; -import com.digitalsanctuary.spring.user.jdbc.Jdbc; - -@Tag("ui") -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -@ActiveProfiles("test") -public class SpringUserFrameworkUiTest extends BaseUiTest { - - private static final String URI = "http://localhost:8080/"; - - private static final UserDto testUser = UiTestData.getUserDto(); - - @Value("${test.browser}") - private String browser; - - @BeforeAll - public void setBrowser() { - switch (browser) { - case "chrome" -> super.setDriver(Driver.CHROME); - case "opera" -> super.setDriver(Driver.OPERA); - case "firefox" -> super.setDriver(Driver.FIREFOX); - case "edge" -> super.setDriver(Driver.EDGE); - } - } - - { - super.setDriver(Driver.CHROME); - } - - @AfterEach - public void deleteTestUser() { - Jdbc.deleteTestUser(testUser); - } - - @Test - public void successSignUp() { - SuccessRegisterPage registerPage = new RegisterPage(URI + "user/register.html").signUp(testUser.getFirstName(), testUser.getLastName(), - testUser.getEmail(), testUser.getPassword(), testUser.getMatchingPassword()); - String actualMessage = registerPage.message(); - Assertions.assertEquals(SUCCESS_SING_UP_MESSAGE, actualMessage); - } - - @Test - public void userAlreadyExistSignUp() { - Jdbc.saveTestUser(testUser); - RegisterPage registerPage = new RegisterPage(URI + "user/register.html"); - registerPage.signUp(testUser.getFirstName(), testUser.getLastName(), testUser.getEmail(), testUser.getPassword(), - testUser.getMatchingPassword()); - String actualMessage = registerPage.accountExistErrorMessage(); - Assertions.assertEquals(ACCOUNT_EXIST_ERROR_MESSAGE, actualMessage); - } - - /** - * checks that welcome message in success login page contains username - */ - @Test - public void successSignIn() { - Jdbc.saveTestUser(testUser); - LoginPage loginPage = new LoginPage(URI + "user/login.html"); - LoginSuccessPage loginSuccessPage = loginPage.signIn(testUser.getEmail(), testUser.getPassword()); - String welcomeMessage = loginSuccessPage.welcomeMessage(); - String firstName = testUser.getFirstName(); - Assertions.assertTrue(welcomeMessage.contains(firstName)); - } - - @Test - public void successResetPassword() { - Jdbc.saveTestUser(testUser); - ForgotPasswordPage forgotPasswordPage = new ForgotPasswordPage(URI + "user/forgot-password.html"); - SuccessResetPasswordPage successResetPasswordPage = forgotPasswordPage.fillEmail(testUser.getEmail()).clickSubmitBtn(); - String actualMessage = successResetPasswordPage.message(); - Assertions.assertEquals(SUCCESS_RESET_PASSWORD_MESSAGE, actualMessage); - - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/data/UiTestData.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/data/UiTestData.java deleted file mode 100644 index 811bc7f..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/data/UiTestData.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.data; - -import com.digitalsanctuary.spring.user.dto.UserDto; - -public class UiTestData { - - public static final String ACCOUNT_EXIST_ERROR_MESSAGE = - "An account for that username/email already exists. " + "Please enter a different email."; - - public static final String SUCCESS_SING_UP_MESSAGE = "Thank you for registering!"; - - public static final String SUCCESS_RESET_PASSWORD_MESSAGE = "You should receive a password reset email shortly"; - - public static final String TEST_USER_ENCODED_PASSWORD = "$2y$10$XIRn/npMMCGt21gpU6QAbeOAUSxj/C7A793YZe.a6AEvL0LhQwkqW"; - - public static UserDto getUserDto() { - UserDto userDto = new UserDto(); - userDto.setFirstName("testUiUser"); - userDto.setLastName("userUiTest"); - userDto.setEmail("testUiUser@bk.com"); - userDto.setPassword("testUiUserPassword"); - userDto.setMatchingPassword(userDto.getPassword()); - return userDto; - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/DeleteAccountPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/DeleteAccountPage.java deleted file mode 100644 index 597fb01..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/DeleteAccountPage.java +++ /dev/null @@ -1,132 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import static com.codeborne.selenide.Condition.appear; -import static com.codeborne.selenide.Condition.cssClass; -import static com.codeborne.selenide.Condition.disappear; -import static com.codeborne.selenide.Condition.visible; -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; - -/** - * Page object for delete account page - */ -public class DeleteAccountPage { - - private final SelenideElement DELETE_ACCOUNT_BUTTON = Selenide.$x("//button[@type='submit']"); - private final SelenideElement DELETE_CONFIRMATION_MODAL = Selenide.$x("//div[@id='deleteConfirmationModal']"); - private final SelenideElement CONFIRMATION_INPUT = Selenide.$x("//input[@id='deleteConfirmationInput']"); - private final SelenideElement CONFIRM_DELETION_BUTTON = Selenide.$x("//button[@id='confirmDeletionButton']"); - private final SelenideElement CANCEL_BUTTON = Selenide.$x("//button[contains(@class, 'btn-secondary')]"); - private final SelenideElement GLOBAL_MESSAGE = Selenide.$x("//div[@id='globalMessage']"); - private final SelenideElement GLOBAL_ERROR = Selenide.$x("//div[@id='globalError']"); - - public DeleteAccountPage(String url) { - Selenide.open(url); - } - - public DeleteAccountPage() { - // For navigation to this page from other pages - } - - /** - * Start the account deletion process by clicking the delete button - */ - public DeleteAccountPage startDeletion() { - DELETE_ACCOUNT_BUTTON.click(); - // Wait for modal to appear - DELETE_CONFIRMATION_MODAL.should(appear); - return this; - } - - /** - * Complete account deletion with confirmation - */ - public DeleteAccountPage confirmDeletion(String confirmationText) { - CONFIRMATION_INPUT.setValue(confirmationText); - CONFIRM_DELETION_BUTTON.click(); - return this; - } - - /** - * Complete account deletion with correct confirmation text - */ - public DeleteAccountPage confirmDeletion() { - return confirmDeletion("DELETE"); - } - - /** - * Cancel the deletion process - */ - public DeleteAccountPage cancelDeletion() { - CANCEL_BUTTON.click(); - // Wait for modal to disappear - DELETE_CONFIRMATION_MODAL.should(disappear); - return this; - } - - /** - * Get success message after deletion - */ - public String getSuccessMessage() { - return GLOBAL_MESSAGE.shouldBe(visible).text(); - } - - /** - * Get error message - */ - public String getErrorMessage() { - return GLOBAL_ERROR.shouldBe(visible).text(); - } - - /** - * Check if deletion was successful - */ - public boolean isDeletionSuccessful() { - return GLOBAL_MESSAGE.has(cssClass("alert-success")); - } - - /** - * Check if there's an error message - */ - public boolean hasError() { - return GLOBAL_ERROR.exists() && GLOBAL_ERROR.isDisplayed(); - } - - /** - * Check if the confirmation modal is visible - */ - public boolean isConfirmationModalVisible() { - return DELETE_CONFIRMATION_MODAL.has(cssClass("show")); - } - - /** - * Check if the delete form is hidden (after successful deletion) - */ - public boolean isDeleteFormHidden() { - SelenideElement form = Selenide.$x("//form[@id='deleteAccountForm']"); - return form.has(cssClass("d-none")); - } - - /** - * Wait for page to load completely - */ - public DeleteAccountPage waitForPageLoad() { - DELETE_ACCOUNT_BUTTON.should(appear); - return this; - } - - /** - * Clear confirmation input - */ - public DeleteAccountPage clearConfirmationInput() { - CONFIRMATION_INPUT.clear(); - return this; - } - - /** - * Get the current confirmation input value - */ - public String getConfirmationInputValue() { - return CONFIRMATION_INPUT.getValue(); - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/ForgotPasswordPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/ForgotPasswordPage.java deleted file mode 100644 index ed3d020..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/ForgotPasswordPage.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import static com.codeborne.selenide.Selenide.$; -import static com.codeborne.selenide.Selenide.$x; -import org.openqa.selenium.By; -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; -import com.digitalsanctuary.spring.demo.user.ui.BaseUiTest; - -public class ForgotPasswordPage extends BaseUiTest { - private final SelenideElement EMAIL_FIELD = $(By.id("email")); - private final SelenideElement SUBMIT_BTN = $x("//button"); - - public ForgotPasswordPage(String url) { - Selenide.open(url); - } - - public ForgotPasswordPage fillEmail(String email) { - EMAIL_FIELD.setValue(email); - return this; - } - - public SuccessResetPasswordPage clickSubmitBtn() { - SUBMIT_BTN.click(); - return new SuccessResetPasswordPage(); - } - -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/LoginPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/LoginPage.java deleted file mode 100644 index 83b074a..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/LoginPage.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; - -public class LoginPage { - - private final SelenideElement EMAIL_FIELD = Selenide.$x("//input[@id='username']"); - - private final SelenideElement PASSWORD_FIELD = Selenide.$x("//input[@id='password']"); - - private final SelenideElement LOGIN_BUTTON = Selenide.$x("//button[@id='loginButton']"); - - public LoginPage(String url) { - Selenide.open(url); - } - - public LoginSuccessPage signIn(String email, String password) { - EMAIL_FIELD.setValue(email); - PASSWORD_FIELD.setValue(password); - LOGIN_BUTTON.click(); - return new LoginSuccessPage(); - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/LoginSuccessPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/LoginSuccessPage.java deleted file mode 100644 index 98f6720..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/LoginSuccessPage.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import com.codeborne.selenide.ElementsCollection; -import com.codeborne.selenide.Selenide; - -public class LoginSuccessPage { - - private final ElementsCollection WELCOME_MESSAGE = Selenide.$$x("//section//div//span"); - - public String welcomeMessage() { - return String.format("%s %s", WELCOME_MESSAGE.get(0).text(), WELCOME_MESSAGE.get(1).text()); - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/RegisterPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/RegisterPage.java deleted file mode 100644 index d8adbd4..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/RegisterPage.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; - -/** - * Register page - */ -public class RegisterPage { - - private final SelenideElement FIRST_NAME_FIELD = Selenide.$x("//input[@id='firstName']"); - - private final SelenideElement LAST_NAME_FIELD = Selenide.$x("//input[@id='lastName']"); - - private final SelenideElement EMAIL_FIELD = Selenide.$x("//input[@id='email']"); - - private final SelenideElement PASSWORD_FIELD = Selenide.$x("//input[@id='password']"); - - private final SelenideElement CONFIRM_PASSWORD_FIELD = Selenide.$x("//input[@id='matchPassword']"); - - private final SelenideElement SIGN_UP_BUTTON = Selenide.$x("//button[@id='signUpButton']"); - - private final SelenideElement ACCOUNT_EXIST_ERROR_MESSAGE = Selenide.$x("//div[@id='existingAccountError']//span"); - - public RegisterPage(String url) { - Selenide.open(url); - } - - /** - * Filling register form and click signUp button - */ - public SuccessRegisterPage signUp(String firstName, String lastName, String email, String password, String confirmPassword) { - FIRST_NAME_FIELD.setValue(firstName); - LAST_NAME_FIELD.setValue(lastName); - EMAIL_FIELD.setValue(email); - PASSWORD_FIELD.setValue(password); - CONFIRM_PASSWORD_FIELD.setValue(confirmPassword); - SIGN_UP_BUTTON.click(); - return new SuccessRegisterPage(); - } - - public String accountExistErrorMessage() { - return ACCOUNT_EXIST_ERROR_MESSAGE.text(); - } - -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/SuccessRegisterPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/SuccessRegisterPage.java deleted file mode 100644 index 816f68e..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/SuccessRegisterPage.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import com.codeborne.selenide.SelenideElement; - -import static com.codeborne.selenide.Selenide.$x; - -public class SuccessRegisterPage { - private final SelenideElement SUCCESS_MESSAGE = $x("//section//div//h1"); - - public String message() { - return SUCCESS_MESSAGE.text(); - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/SuccessResetPasswordPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/SuccessResetPasswordPage.java deleted file mode 100644 index 54671ed..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/SuccessResetPasswordPage.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; -import com.digitalsanctuary.spring.demo.user.ui.BaseUiTest; - -public class SuccessResetPasswordPage extends BaseUiTest { - private final SelenideElement SUCCESS_RESET_MESSAGE = Selenide.$x("//div[@class='container']//div[@class='container']//span"); - - public String message() { - return SUCCESS_RESET_MESSAGE.text(); - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/UpdatePasswordPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/UpdatePasswordPage.java deleted file mode 100644 index a66282a..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/UpdatePasswordPage.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import static com.codeborne.selenide.Condition.appear; -import static com.codeborne.selenide.Condition.cssClass; -import static com.codeborne.selenide.Condition.visible; -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; - -/** - * Page object for update password page - */ -public class UpdatePasswordPage { - - private final SelenideElement CURRENT_PASSWORD_FIELD = Selenide.$x("//input[@id='oldPassword']"); - private final SelenideElement NEW_PASSWORD_FIELD = Selenide.$x("//input[@id='password']"); - private final SelenideElement CONFIRM_PASSWORD_FIELD = Selenide.$x("//input[@id='matchPassword']"); - private final SelenideElement UPDATE_PASSWORD_BUTTON = Selenide.$x("//button[@type='submit']"); - private final SelenideElement GLOBAL_MESSAGE = Selenide.$x("//div[@id='globalMessage']"); - private final SelenideElement BACK_TO_PROFILE_LINK = Selenide.$x("//a[contains(@href, 'update-user')]"); - - public UpdatePasswordPage(String url) { - Selenide.open(url); - } - - public UpdatePasswordPage() { - // For navigation to this page from other pages - } - - /** - * Update user password - */ - public UpdatePasswordPage updatePassword(String currentPassword, String newPassword, String confirmPassword) { - CURRENT_PASSWORD_FIELD.setValue(currentPassword); - NEW_PASSWORD_FIELD.setValue(newPassword); - CONFIRM_PASSWORD_FIELD.setValue(confirmPassword); - UPDATE_PASSWORD_BUTTON.click(); - - // Wait for response message - GLOBAL_MESSAGE.should(appear); - return this; - } - - /** - * Update password with matching new password - */ - public UpdatePasswordPage updatePassword(String currentPassword, String newPassword) { - return updatePassword(currentPassword, newPassword, newPassword); - } - - /** - * Get the success/error message displayed after password update - */ - public String getMessage() { - return GLOBAL_MESSAGE.shouldBe(visible).text(); - } - - /** - * Check if password update was successful - */ - public boolean isPasswordUpdateSuccessful() { - return GLOBAL_MESSAGE.has(cssClass("alert-success")); - } - - /** - * Check if there's a password mismatch error - */ - public boolean hasPasswordMismatchError() { - SelenideElement matchPasswordError = CONFIRM_PASSWORD_FIELD.parent().$(".form-text.text-danger"); - return matchPasswordError.exists() && matchPasswordError.isDisplayed(); - } - - /** - * Navigate back to profile page - */ - public UpdateUserPage goBackToProfile() { - BACK_TO_PROFILE_LINK.click(); - return new UpdateUserPage(); - } - - /** - * Wait for page to load completely - */ - public UpdatePasswordPage waitForPageLoad() { - CURRENT_PASSWORD_FIELD.should(appear); - NEW_PASSWORD_FIELD.should(appear); - CONFIRM_PASSWORD_FIELD.should(appear); - UPDATE_PASSWORD_BUTTON.should(appear); - return this; - } - - /** - * Clear all form fields - */ - public UpdatePasswordPage clearAllFields() { - CURRENT_PASSWORD_FIELD.clear(); - NEW_PASSWORD_FIELD.clear(); - CONFIRM_PASSWORD_FIELD.clear(); - return this; - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/UpdateUserPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/UpdateUserPage.java deleted file mode 100644 index 2b23607..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/UpdateUserPage.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import static com.codeborne.selenide.Condition.appear; -import static com.codeborne.selenide.Condition.cssClass; -import static com.codeborne.selenide.Condition.visible; -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; - -/** - * Page object for update user profile page - */ -public class UpdateUserPage { - - private final SelenideElement FIRST_NAME_FIELD = Selenide.$x("//input[@id='firstName']"); - private final SelenideElement LAST_NAME_FIELD = Selenide.$x("//input[@id='lastName']"); - private final SelenideElement UPDATE_BUTTON = Selenide.$x("//button[@type='submit']"); - private final SelenideElement GLOBAL_MESSAGE = Selenide.$x("//div[@id='globalMessage']"); - private final SelenideElement CHANGE_PASSWORD_LINK = Selenide.$x("//a[@href='/user/update-password.html']"); - private final SelenideElement DELETE_ACCOUNT_LINK = Selenide.$x("//a[@href='/user/delete-account.html']"); - - public UpdateUserPage(String url) { - Selenide.open(url); - } - - public UpdateUserPage() { - // For navigation to this page from other pages - } - - /** - * Update user profile information - */ - public UpdateUserPage updateProfile(String firstName, String lastName) { - FIRST_NAME_FIELD.clear(); - FIRST_NAME_FIELD.setValue(firstName); - LAST_NAME_FIELD.clear(); - LAST_NAME_FIELD.setValue(lastName); - UPDATE_BUTTON.click(); - - // Wait for success message to appear - GLOBAL_MESSAGE.should(appear); - return this; - } - - /** - * Get the success/error message displayed after update - */ - public String getMessage() { - return GLOBAL_MESSAGE.shouldBe(visible).text(); - } - - /** - * Check if update was successful - */ - public boolean isUpdateSuccessful() { - return GLOBAL_MESSAGE.has(cssClass("alert-success")); - } - - /** - * Navigate to change password page - */ - public UpdatePasswordPage goToChangePassword() { - CHANGE_PASSWORD_LINK.click(); - return new UpdatePasswordPage(); - } - - /** - * Navigate to delete account page - */ - public DeleteAccountPage goToDeleteAccount() { - DELETE_ACCOUNT_LINK.click(); - return new DeleteAccountPage(); - } - - /** - * Get current first name value - */ - public String getFirstName() { - return FIRST_NAME_FIELD.getValue(); - } - - /** - * Get current last name value - */ - public String getLastName() { - return LAST_NAME_FIELD.getValue(); - } - - /** - * Wait for page to load completely - */ - public UpdateUserPage waitForPageLoad() { - FIRST_NAME_FIELD.should(appear); - LAST_NAME_FIELD.should(appear); - UPDATE_BUTTON.should(appear); - return this; - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/VerificationPendingPage.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/VerificationPendingPage.java deleted file mode 100644 index 2d5e700..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/page/VerificationPendingPage.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.page; - -import static com.codeborne.selenide.Condition.appear; -import static com.codeborne.selenide.Condition.visible; -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; - -/** - * Page object for registration pending verification page - */ -public class VerificationPendingPage { - - private final SelenideElement VERIFICATION_MESSAGE = Selenide.$x("//div[contains(@class, 'alert')]"); - private final SelenideElement RESEND_VERIFICATION_LINK = Selenide.$x("//a[contains(@href, 'resend')]"); - private final SelenideElement LOGIN_LINK = Selenide.$x("//a[contains(@href, 'login')]"); - - public VerificationPendingPage(String url) { - Selenide.open(url); - } - - public VerificationPendingPage() { - // For navigation to this page from other pages - } - - /** - * Get the verification pending message - */ - public String getVerificationMessage() { - return VERIFICATION_MESSAGE.shouldBe(visible).text(); - } - - /** - * Click the resend verification email link - */ - public VerificationPendingPage resendVerificationEmail() { - RESEND_VERIFICATION_LINK.click(); - return this; - } - - /** - * Navigate to login page - */ - public LoginPage goToLogin() { - LOGIN_LINK.click(); - return new LoginPage("http://localhost:8080/user/login.html"); - } - - /** - * Check if the verification message is displayed - */ - public boolean isVerificationMessageDisplayed() { - return VERIFICATION_MESSAGE.exists() && VERIFICATION_MESSAGE.isDisplayed(); - } - - /** - * Check if resend verification link is available - */ - public boolean isResendLinkAvailable() { - return RESEND_VERIFICATION_LINK.exists() && RESEND_VERIFICATION_LINK.isDisplayed(); - } - - /** - * Wait for page to load completely - */ - public VerificationPendingPage waitForPageLoad() { - VERIFICATION_MESSAGE.should(appear); - return this; - } - - /** - * Simulate email verification by constructing verification URL This method simulates clicking the verification link from email - */ - public LoginSuccessPage simulateEmailVerification(String verificationToken) { - String verificationUrl = "http://localhost:8080/user/registrationConfirm?token=" + verificationToken; - Selenide.open(verificationUrl); - return new LoginSuccessPage(); - } - - /** - * Simulate email verification with an invalid token - */ - public VerificationPendingPage simulateInvalidEmailVerification() { - String verificationUrl = "http://localhost:8080/user/registrationConfirm?token=invalid-token"; - Selenide.open(verificationUrl); - return this; - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/util/DatabaseStateValidator.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/util/DatabaseStateValidator.java deleted file mode 100644 index a810435..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/util/DatabaseStateValidator.java +++ /dev/null @@ -1,215 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.util; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; - -/** - * Utility class for validating database state during UI tests - */ -public class DatabaseStateValidator { - - private static final String USER_EXISTS_QUERY = - "SELECT COUNT(*) FROM user_account WHERE email = ?"; - - private static final String USER_ENABLED_QUERY = - "SELECT enabled FROM user_account WHERE email = ?"; - - private static final String USER_LOCKED_QUERY = - "SELECT locked FROM user_account WHERE email = ?"; - - private static final String USER_DETAILS_QUERY = - "SELECT first_name, last_name, enabled, locked, failed_login_attempts FROM user_account WHERE email = ?"; - - private static final String VERIFICATION_TOKEN_EXISTS_QUERY = - "SELECT COUNT(*) FROM verification_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - - private static final String PASSWORD_RESET_TOKEN_EXISTS_QUERY = - "SELECT COUNT(*) FROM password_reset_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - - /** - * Check if a user exists in the database - */ - public static boolean userExists(String userEmail) { - try (Connection connection = com.digitalsanctuary.spring.user.jdbc.ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(USER_EXISTS_QUERY); - statement.setString(1, userEmail); - ResultSet resultSet = statement.executeQuery(); - - return resultSet.next() && resultSet.getInt(1) > 0; - } catch (SQLException e) { - throw new RuntimeException("Failed to check if user exists: " + userEmail, e); - } - } - - /** - * Check if a user is enabled - */ - public static boolean isUserEnabled(String userEmail) { - try (Connection connection = com.digitalsanctuary.spring.user.jdbc.ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(USER_ENABLED_QUERY); - statement.setString(1, userEmail); - ResultSet resultSet = statement.executeQuery(); - - return resultSet.next() && resultSet.getBoolean(1); - } catch (SQLException e) { - throw new RuntimeException("Failed to check if user is enabled: " + userEmail, e); - } - } - - /** - * Check if a user account is locked - */ - public static boolean isUserLocked(String userEmail) { - try (Connection connection = com.digitalsanctuary.spring.user.jdbc.ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(USER_LOCKED_QUERY); - statement.setString(1, userEmail); - ResultSet resultSet = statement.executeQuery(); - - return resultSet.next() && resultSet.getBoolean(1); - } catch (SQLException e) { - throw new RuntimeException("Failed to check if user is locked: " + userEmail, e); - } - } - - /** - * Get user details for validation - */ - public static UserDetails getUserDetails(String userEmail) { - try (Connection connection = com.digitalsanctuary.spring.user.jdbc.ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(USER_DETAILS_QUERY); - statement.setString(1, userEmail); - ResultSet resultSet = statement.executeQuery(); - - if (resultSet.next()) { - return new UserDetails( - resultSet.getString("first_name"), - resultSet.getString("last_name"), - resultSet.getBoolean("enabled"), - resultSet.getBoolean("locked"), - resultSet.getInt("failed_login_attempts") - ); - } - return null; - } catch (SQLException e) { - throw new RuntimeException("Failed to get user details: " + userEmail, e); - } - } - - /** - * Check if a verification token exists for the user - */ - public static boolean hasVerificationToken(String userEmail) { - try (Connection connection = com.digitalsanctuary.spring.user.jdbc.ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(VERIFICATION_TOKEN_EXISTS_QUERY); - statement.setString(1, userEmail); - ResultSet resultSet = statement.executeQuery(); - - return resultSet.next() && resultSet.getInt(1) > 0; - } catch (SQLException e) { - throw new RuntimeException("Failed to check verification token: " + userEmail, e); - } - } - - /** - * Check if a password reset token exists for the user - */ - public static boolean hasPasswordResetToken(String userEmail) { - try (Connection connection = com.digitalsanctuary.spring.user.jdbc.ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(PASSWORD_RESET_TOKEN_EXISTS_QUERY); - statement.setString(1, userEmail); - ResultSet resultSet = statement.executeQuery(); - - return resultSet.next() && resultSet.getInt(1) > 0; - } catch (SQLException e) { - throw new RuntimeException("Failed to check password reset token: " + userEmail, e); - } - } - - /** - * Validate complete user registration state - */ - public static void validateUserRegistered(String userEmail, String firstName, String lastName) { - if (!userExists(userEmail)) { - throw new AssertionError("User should exist after registration: " + userEmail); - } - - UserDetails details = getUserDetails(userEmail); - if (!details.getFirstName().equals(firstName)) { - throw new AssertionError("First name mismatch. Expected: " + firstName + ", Actual: " + details.getFirstName()); - } - - if (!details.getLastName().equals(lastName)) { - throw new AssertionError("Last name mismatch. Expected: " + lastName + ", Actual: " + details.getLastName()); - } - - if (!hasVerificationToken(userEmail)) { - throw new AssertionError("Verification token should exist after registration: " + userEmail); - } - } - - /** - * Validate user email verification completed - */ - public static void validateEmailVerified(String userEmail) { - if (!isUserEnabled(userEmail)) { - throw new AssertionError("User should be enabled after email verification: " + userEmail); - } - - if (hasVerificationToken(userEmail)) { - throw new AssertionError("Verification token should be consumed after verification: " + userEmail); - } - } - - /** - * Validate user profile update - */ - public static void validateProfileUpdated(String userEmail, String newFirstName, String newLastName) { - UserDetails details = getUserDetails(userEmail); - if (!details.getFirstName().equals(newFirstName)) { - throw new AssertionError("First name not updated. Expected: " + newFirstName + ", Actual: " + details.getFirstName()); - } - - if (!details.getLastName().equals(newLastName)) { - throw new AssertionError("Last name not updated. Expected: " + newLastName + ", Actual: " + details.getLastName()); - } - } - - /** - * Validate user account deletion - */ - public static void validateAccountDeleted(String userEmail) { - if (userExists(userEmail)) { - // Could be soft delete - check if disabled instead - if (isUserEnabled(userEmail)) { - throw new AssertionError("User account should be deleted or disabled: " + userEmail); - } - } - } - - /** - * Data class for user details - */ - public static class UserDetails { - private final String firstName; - private final String lastName; - private final boolean enabled; - private final boolean locked; - private final int failedLoginAttempts; - - public UserDetails(String firstName, String lastName, boolean enabled, boolean locked, int failedLoginAttempts) { - this.firstName = firstName; - this.lastName = lastName; - this.enabled = enabled; - this.locked = locked; - this.failedLoginAttempts = failedLoginAttempts; - } - - public String getFirstName() { return firstName; } - public String getLastName() { return lastName; } - public boolean isEnabled() { return enabled; } - public boolean isLocked() { return locked; } - public int getFailedLoginAttempts() { return failedLoginAttempts; } - } -} \ No newline at end of file diff --git a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/util/EmailVerificationSimulator.java b/src/test/java/com/digitalsanctuary/spring/demo/user/ui/util/EmailVerificationSimulator.java deleted file mode 100644 index f019c1d..0000000 --- a/src/test/java/com/digitalsanctuary/spring/demo/user/ui/util/EmailVerificationSimulator.java +++ /dev/null @@ -1,92 +0,0 @@ -package com.digitalsanctuary.spring.demo.user.ui.util; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; - -/** - * Utility class for simulating email verification in UI tests - */ -public class EmailVerificationSimulator { - - private static final String GET_VERIFICATION_TOKEN_QUERY = - "SELECT token FROM verification_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - - private static final String ENABLE_USER_QUERY = "UPDATE user_account SET enabled = true WHERE email = ?"; - - private static final String DELETE_VERIFICATION_TOKEN_QUERY = - "DELETE FROM verification_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - - /** - * Get verification token for a user by email In a real application, this would come from the email content - */ - public static String getVerificationToken(String userEmail) { - try (Connection connection = com.digitalsanctuary.spring.user.jdbc.ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(GET_VERIFICATION_TOKEN_QUERY); - statement.setString(1, userEmail); - ResultSet resultSet = statement.executeQuery(); - - if (resultSet.next()) { - return resultSet.getString(1); - } - return null; - } catch (SQLException e) { - throw new RuntimeException("Failed to get verification token for " + userEmail, e); - } - } - - /** - * Simulate email verification by directly enabling the user This bypasses the actual email verification endpoint - */ - public static void simulateEmailVerification(String userEmail) { - try (Connection connection = com.digitalsanctuary.spring.user.jdbc.ConnectionManager.open()) { - // Enable the user - PreparedStatement enableStatement = connection.prepareStatement(ENABLE_USER_QUERY); - enableStatement.setString(1, userEmail); - enableStatement.executeUpdate(); - - // Delete the verification token (consumed) - PreparedStatement deleteStatement = connection.prepareStatement(DELETE_VERIFICATION_TOKEN_QUERY); - deleteStatement.setString(1, userEmail); - deleteStatement.executeUpdate(); - - } catch (SQLException e) { - throw new RuntimeException("Failed to simulate email verification for " + userEmail, e); - } - } - - /** - * Check if user has a pending verification token - */ - public static boolean hasVerificationToken(String userEmail) { - return getVerificationToken(userEmail) != null; - } - - /** - * Generate a mock verification URL for testing - */ - public static String generateVerificationUrl(String userEmail) { - String token = getVerificationToken(userEmail); - if (token == null) { - throw new RuntimeException("No verification token found for user: " + userEmail); - } - return "http://localhost:8080/user/registrationConfirm?token=" + token; - } - - /** - * Generate an invalid verification URL for testing error scenarios - */ - public static String generateInvalidVerificationUrl() { - return "http://localhost:8080/user/registrationConfirm?token=invalid-token-12345"; - } - - /** - * Get verification token directly from database (for testing purposes) This simulates what would normally be extracted from the email content - */ - public static String extractTokenFromEmail(String userEmail) { - // In a real test, this would parse the email content - // For our simulation, we get it directly from the database - return getVerificationToken(userEmail); - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java b/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java deleted file mode 100644 index 4f8e9ed..0000000 --- a/src/test/java/com/digitalsanctuary/spring/user/jdbc/ConnectionManager.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.digitalsanctuary.spring.user.jdbc; - -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; - -public class ConnectionManager { - - private static final String driver = "org.mariadb.jdbc.Driver"; - - private static final String url = "jdbc:mariadb://127.0.0.1:3306/springuser"; - - private static final String username = "springuser"; - - private static final String password = "springuser"; - - static { - initDriver(); - } - - private static void initDriver() { - try { - Class.forName(driver); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - } - - public static Connection open() { - try { - return DriverManager.getConnection(url, username, password); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - -} diff --git a/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java b/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java deleted file mode 100644 index f443ed5..0000000 --- a/src/test/java/com/digitalsanctuary/spring/user/jdbc/Jdbc.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.digitalsanctuary.spring.user.jdbc; - -import static com.digitalsanctuary.spring.demo.user.ui.data.UiTestData.TEST_USER_ENCODED_PASSWORD; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import com.digitalsanctuary.spring.user.dto.UserDto; - -/** - * Using for delete/save user test data - */ -public class Jdbc { - private static final String DELETE_VERIFICATION_TOKEN_QUERY = - "DELETE FROM verification_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_USER_ROLE = "DELETE FROM users_roles WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_PASSWORD_RESET_TOKEN = - "DELETE FROM password_reset_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - private static final String DELETE_TEST_USER_QUERY = "DELETE FROM user_account WHERE email = ?"; - private static final String GET_LAST_USER_ID_QUERY = "SELECT max(id) FROM user_account"; - private static final String GET_PASSWORD_RESET_TOKEN_BY_USER_EMAIL_QUERY = - "SELECT token FROM password_reset_token WHERE user_id = (SELECT id FROM user_account WHERE email = ?)"; - - private static final String SAVE_TEST_USER_QUERY = "INSERT INTO user_account (id, first_name, last_name, email, " - + "password, enabled, failed_login_attempts, locked) VALUES (?,?,?,?,?,?,?,?)"; - - public static void deleteTestUser(UserDto userDto) { - try (Connection connection = ConnectionManager.open()) { - String[] params = new String[] {userDto.getEmail()}; - execute(connection, DELETE_VERIFICATION_TOKEN_QUERY, params); - execute(connection, DELETE_TEST_USER_ROLE, params); - execute(connection, DELETE_TEST_PASSWORD_RESET_TOKEN, params); - execute(connection, DELETE_TEST_USER_QUERY, params); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - - public static void saveTestUser(UserDto userDto) { - try (Connection connection = ConnectionManager.open()) { - ResultSet resultSet = connection.prepareStatement(GET_LAST_USER_ID_QUERY).executeQuery(); - int id = 0; - if (resultSet.next()) { - id = (resultSet.getInt(1) + 1); - } - Object[] params = - new Object[] {id, userDto.getFirstName(), userDto.getLastName(), userDto.getEmail(), TEST_USER_ENCODED_PASSWORD, true, 0, false}; - execute(connection, SAVE_TEST_USER_QUERY, params); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - private static void execute(Connection connection, String query, Object[] params) throws SQLException { - PreparedStatement statement = connection.prepareStatement(query); - for (int i = 0; i < params.length; i++) { - Object param = params[i]; - if (param instanceof Integer) { - statement.setInt((i + 1), (Integer) param); - } - if (param instanceof String) { - statement.setString((i + 1), (String) param); - } - if (param instanceof Boolean) { - statement.setBoolean((i + 1), (Boolean) param); - } - } - statement.executeUpdate(); - } - - public static String getPasswordRestTokenByUserEmail(String email) { - String token = ""; - try (Connection connection = ConnectionManager.open()) { - PreparedStatement statement = connection.prepareStatement(GET_PASSWORD_RESET_TOKEN_BY_USER_EMAIL_QUERY); - statement.setString(1, email); - ResultSet set = statement.executeQuery(); - if (set.next()) { - token = set.getString(1); - } - } catch (SQLException e) { - throw new RuntimeException(e); - } - return token; - } -} diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java index 76a61f3..b9e6923 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java +++ b/src/test/java/com/digitalsanctuary/spring/user/security/EmailVerificationEdgeCaseTest.java @@ -35,7 +35,6 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import com.digitalsanctuary.spring.demo.UserDemoApplication; -import com.digitalsanctuary.spring.demo.user.ui.util.DatabaseStateValidator; import com.digitalsanctuary.spring.user.persistence.model.User; import com.digitalsanctuary.spring.user.persistence.model.VerificationToken; import com.digitalsanctuary.spring.user.persistence.repository.PasswordHistoryRepository; @@ -133,7 +132,7 @@ void testExpiredTokenRejection() throws Exception { .andExpect(status().isOk()).andReturn(); // Verify user remains disabled - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isFalse(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isFalse(); // Verify token was cleaned up assertThat(verificationTokenRepository.findByToken(expiredToken.getToken())).isNull(); @@ -158,7 +157,7 @@ void testJustExpiredToken() throws Exception { .andExpect(status().isOk()); // Verify user remains disabled - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isFalse(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isFalse(); // Verify token was cleaned up assertThat(verificationTokenRepository.findByToken(justExpiredToken.getToken())).isNull(); @@ -179,7 +178,7 @@ void testTokenNearExpiry() throws Exception { .andExpect(status().is3xxRedirection()); // Successful verification redirects // Verify user is enabled - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isTrue(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isTrue(); // Verify token was consumed assertThat(verificationTokenRepository.findByToken(nearExpiryToken.getToken())).isNull(); @@ -210,7 +209,7 @@ void testMultipleTokenRequests() throws Exception { .andExpect(status().is3xxRedirection()); // Verify user is enabled - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isTrue(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isTrue(); } } @@ -239,7 +238,7 @@ void testInvalidTokenFormats() throws Exception { .andReturn(); // Verify user remains disabled - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isFalse(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isFalse(); // Verify error message is user-friendly, not a stack trace String responseBody = result.getResponse().getContentAsString(); @@ -277,7 +276,7 @@ void testTamperedTokens() throws Exception { .andExpect(status().isOk()).andReturn(); // Verify user remains disabled - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isFalse(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isFalse(); // Verify original token still exists (not consumed by tampered attempt) assertThat(verificationTokenRepository.findByToken(originalToken)).isNotNull(); @@ -304,10 +303,10 @@ void testCrossUserTokenAttack() throws Exception { .andExpect(status().isOk()); // Verify our test user remains disabled - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isFalse(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isFalse(); // Verify other user also remains disabled (token not consumed) - assertThat(DatabaseStateValidator.isUserEnabled(otherEmail)).isFalse(); + assertThat(userRepository.findByEmail(otherEmail).isEnabled()).isFalse(); // Verify token still exists (not consumed) assertThat(verificationTokenRepository.findByToken(otherUserToken.getToken())).isNotNull(); @@ -329,7 +328,7 @@ void testAlreadyUsedToken() throws Exception { .andExpect(status().is3xxRedirection()); // Verify user is enabled - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isTrue(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isTrue(); // Verify token was consumed assertThat(verificationTokenRepository.findByToken(tokenValue)).isNull(); @@ -402,7 +401,7 @@ void testConcurrentTokenValidation() throws Exception { assertThat(failureCount.get()).isEqualTo(threadCount - 1); // Verify user is enabled (successful verification) - assertThat(DatabaseStateValidator.isUserEnabled(testEmail)).isTrue(); + assertThat(userRepository.findByEmail(testEmail).isEnabled()).isTrue(); // Verify token was consumed assertThat(verificationTokenRepository.findByToken(tokenValue)).isNull(); diff --git a/src/test/java/com/digitalsanctuary/spring/user/ui/page/ForgotPasswordPage.java b/src/test/java/com/digitalsanctuary/spring/user/ui/page/ForgotPasswordPage.java deleted file mode 100644 index eae64db..0000000 --- a/src/test/java/com/digitalsanctuary/spring/user/ui/page/ForgotPasswordPage.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.digitalsanctuary.spring.user.ui.page; - -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; -import com.digitalsanctuary.spring.demo.user.ui.BaseUiTest; -import org.openqa.selenium.By; - -import static com.codeborne.selenide.Selenide.$; -import static com.codeborne.selenide.Selenide.$x; - -public class ForgotPasswordPage extends BaseUiTest { - private final SelenideElement EMAIL_FIELD = $(By.id("email")); - private final SelenideElement SUBMIT_BTN = $x("//button"); - - public ForgotPasswordPage(String url) { - Selenide.open(url); - } - - public ForgotPasswordPage fillEmail(String email) { - EMAIL_FIELD.setValue(email); - return this; - } - - public SuccessResetPasswordPage clickSubmitBtn() { - SUBMIT_BTN.click(); - return new SuccessResetPasswordPage(); - } - -} diff --git a/src/test/java/com/digitalsanctuary/spring/user/ui/page/SuccessResetPasswordPage.java b/src/test/java/com/digitalsanctuary/spring/user/ui/page/SuccessResetPasswordPage.java deleted file mode 100644 index 934edb0..0000000 --- a/src/test/java/com/digitalsanctuary/spring/user/ui/page/SuccessResetPasswordPage.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.digitalsanctuary.spring.user.ui.page; - -import com.codeborne.selenide.Selenide; -import com.codeborne.selenide.SelenideElement; -import com.digitalsanctuary.spring.demo.user.ui.BaseUiTest; - -public class SuccessResetPasswordPage extends BaseUiTest { - private final SelenideElement SUCCESS_RESET_MESSAGE = Selenide.$x("//div[@class='container']//div[@class='container']//span"); - - public String message() { - return SUCCESS_RESET_MESSAGE.text(); - } -} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 218281d..b84556a 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -33,5 +33,3 @@ logging.level.org.springframework=DEBUG #logging.level.org.springframework.data.repository=DEBUG #logging.level.org.hibernate=DEBUG #logging.level.org.springframework.orm.jpa=DEBUG - -test.browser=edge From 4d7e06ed072252a5402e72286a7627596ba088eb Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 17:12:17 -0600 Subject: [PATCH 2/4] chore: upgrade Spring User Framework to 4.3.1 Fixes WebAuthn 500 errors caused by missing user_credentials table (see devondragon/SpringUserFramework#286). Also removes unused DSUserDetails constant from ApiTestData that caused NPE with the new constructor signature. --- build.gradle | 2 +- .../com/digitalsanctuary/spring/user/api/data/ApiTestData.java | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 4bc4358..46404f2 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:4.3.0' + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.3.1' // WebAuthn support (Passkey authentication) implementation 'org.springframework.security:spring-security-webauthn' diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/data/ApiTestData.java b/src/test/java/com/digitalsanctuary/spring/user/api/data/ApiTestData.java index 01156ae..71079fb 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/data/ApiTestData.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/data/ApiTestData.java @@ -2,12 +2,10 @@ import com.digitalsanctuary.spring.user.dto.PasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; -import com.digitalsanctuary.spring.user.service.DSUserDetails; public class ApiTestData { public static final UserDto BASE_TEST_USER = getUserDto(); - public static final DSUserDetails DEFAULT_DETAILS = new DSUserDetails(null, null); public static PasswordDto getPasswordDto() { PasswordDto dto = new PasswordDto(); From 1600fe5b673812b198be8adda73c76e0b9316bdf Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 17:15:13 -0600 Subject: [PATCH 3/4] fix: wait for async error element in duplicate registration test The registration form uses async fetch (no page navigation), so waitForLoadState returns immediately. Wait for the error element to become visible instead of checking synchronously. --- playwright/tests/auth/registration.spec.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/playwright/tests/auth/registration.spec.ts b/playwright/tests/auth/registration.spec.ts index 0fa0db6..b2c6ed3 100644 --- a/playwright/tests/auth/registration.spec.ts +++ b/playwright/tests/auth/registration.spec.ts @@ -89,16 +89,11 @@ test.describe('Registration', () => { await registerPage.acceptTerms(); await registerPage.submit(); - // Wait for response - await page.waitForLoadState('domcontentloaded'); - - // Should show error or redirect with error parameter - const url = page.url(); - const hasError = await registerPage.hasGlobalError() || - await registerPage.hasExistingAccountError() || - url.includes('error'); - - expect(hasError).toBe(true); + // Registration uses async fetch — wait for the error element to appear + // rather than waiting for page navigation (which doesn't happen) + const globalError = page.locator('#globalError'); + const existingAccountError = page.locator('#existingAccountError'); + await expect(globalError.or(existingAccountError)).toBeVisible({ timeout: 10000 }); }); test('should reject mismatched passwords', async ({ From 6f2709af293bc264d36cbdaf7ab7c226ff33bf01 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sun, 22 Mar 2026 17:49:53 -0600 Subject: [PATCH 4/4] fix: address Copilot review feedback on validation test assertions - Empty field submission: assert HTML5 checkValidity() instead of just URL - Long name submission: use updateProfileAndWait() and assert message visible - Weak password: wait for globalMessage with sufficient timeout before asserting error - Mismatched passwords: assert confirmPasswordError element is visible --- .../tests/profile/change-password.spec.ts | 13 +++++----- .../tests/profile/update-profile.spec.ts | 24 ++++++++++--------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/playwright/tests/profile/change-password.spec.ts b/playwright/tests/profile/change-password.spec.ts index fc9ae5e..f95a549 100644 --- a/playwright/tests/profile/change-password.spec.ts +++ b/playwright/tests/profile/change-password.spec.ts @@ -135,13 +135,12 @@ test.describe('Change Password', () => { await updatePasswordPage.goto(); await page.waitForLoadState('domcontentloaded'); - // Try to change to a weak password + // Try to change to a weak password and wait for the server response await updatePasswordPage.changePassword(user.password, 'weak'); - await page.waitForLoadState('domcontentloaded'); + await updatePasswordPage.waitForMessage(10000); - // Should show error or validation message - const url = page.url(); - expect(url).toContain('update-password'); + // Should show an error message (weak password rejected) + expect(await updatePasswordPage.isErrorMessage()).toBe(true); }); test('should reject mismatched new passwords', async ({ @@ -167,9 +166,9 @@ test.describe('Change Password', () => { 'DifferentPass@456!' ); await updatePasswordPage.submit(); - await page.waitForLoadState('domcontentloaded'); - // Should show error or validation message (client-side validation) + // Client-side validation catches mismatched passwords — error div should appear + await expect(updatePasswordPage.confirmPasswordError).toBeVisible({ timeout: 5000 }); }); test('should reject password same as current', async ({ diff --git a/playwright/tests/profile/update-profile.spec.ts b/playwright/tests/profile/update-profile.spec.ts index 401a062..835ccc1 100644 --- a/playwright/tests/profile/update-profile.spec.ts +++ b/playwright/tests/profile/update-profile.spec.ts @@ -185,10 +185,15 @@ test.describe('Update Profile', () => { await updateUserPage.lastNameInput.fill(''); await updateUserPage.submit(); - // HTML5 required validation should prevent submission - // or server should return an error - verify no unhandled crash - // The form should still be on the update page - expect(page.url()).toContain('update-user'); + // HTML5 required validation should block submission — inputs should be invalid + const firstNameIsValid = await updateUserPage.firstNameInput.evaluate( + (el: HTMLInputElement) => el.checkValidity() + ); + const lastNameIsValid = await updateUserPage.lastNameInput.evaluate( + (el: HTMLInputElement) => el.checkValidity() + ); + expect(firstNameIsValid).toBe(false); + expect(lastNameIsValid).toBe(false); }); test('should handle excessively long names gracefully', async ({ @@ -205,15 +210,12 @@ test.describe('Update Profile', () => { await updateUserPage.goto(); await page.waitForLoadState('domcontentloaded'); - // Submit with very long names (300+ chars) + // Submit with very long names (300+ chars) and wait for server response const longName = 'A'.repeat(300); - await updateUserPage.updateProfile(longName, longName); + await updateUserPage.updateProfileAndWait(longName, longName); - // Wait for server response - await page.waitForLoadState('domcontentloaded'); - - // App should handle gracefully - either succeed or show error, not crash - expect(page.url()).toContain('update-user'); + // App should handle gracefully — success or error message, not crash + expect(await updateUserPage.globalMessage.isVisible()).toBe(true); }); });