diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts index 634e71ea..ad2fee64 100644 --- a/e2e/auth.spec.ts +++ b/e2e/auth.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { getPasswordResetUrl } from './utils/mailpit'; async function registerNewUser(page, email, password) { await page.goto(PLAYWRIGHT_BASE_URL + '/register'); @@ -35,14 +36,200 @@ test('can register and delete account', async ({ page }) => { await registerNewUser(page, email, password); await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); await page.getByRole('button', { name: 'Delete Account' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); await page.getByPlaceholder('Password').fill(password); - await page.getByRole('button', { name: 'Delete Account' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click(); await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); await page.goto(PLAYWRIGHT_BASE_URL + '/login'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password').fill(password); await page.getByRole('button', { name: 'Log in' }).click(); - await expect(page.getByRole('paragraph')).toContainText( + await expect(page.getByRole('alert')).toContainText( 'These credentials do not match our records.' ); }); + +test('shows error for invalid email on forgot password', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + + // Request password reset with non-existent email + await page.getByLabel('Email').fill('nonexistent@example.com'); + await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); + + // Should show error message + await expect(page.getByText("We can't find a user with that email address.")).toBeVisible(); +}); + +test('shows browser validation for invalid email format on forgot password', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + + // Request password reset with invalid email format + const emailInput = page.getByLabel('Email'); + await emailInput.fill('notanemail'); + + // Check for browser validation - the input should be invalid + const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid); + expect(isInvalid).toBe(true); +}); + +test('shows browser validation for empty email on forgot password', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + + // The email input is required, so it should be invalid when empty + const emailInput = page.getByLabel('Email'); + + // Check for browser validation - the input should be invalid because it's required and empty + const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing); + expect(isInvalid).toBe(true); +}); + +test('can reset password via email link', async ({ page, request }) => { + // First register a new user + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const originalPassword = 'suchagreatpassword123'; + const newPassword = 'mynewsecurepassword456'; + await registerNewUser(page, email, originalPassword); + + // Log out + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Request password reset + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + await page.getByLabel('Email').fill(email); + await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); + await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); + + // Get password reset URL from email + const resetUrl = await getPasswordResetUrl(request, email); + + // Navigate to reset page + await page.goto(resetUrl); + + // Fill in new password + await page.getByLabel('Password', { exact: true }).fill(newPassword); + await page.getByLabel('Confirm Password').fill(newPassword); + await page.getByRole('button', { name: 'Reset Password' }).click(); + + // Should redirect to login page after successful reset + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Try logging in with new password + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(newPassword); + await page.getByRole('button', { name: 'Log in' }).click(); + await expect(page.getByTestId('dashboard_view')).toBeVisible(); +}); + +test('shows validation error for password mismatch on reset', async ({ page, request }) => { + // First register a new user + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const originalPassword = 'suchagreatpassword123'; + await registerNewUser(page, email, originalPassword); + + // Log out + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Request password reset + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + await page.getByLabel('Email').fill(email); + await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); + await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); + + // Get password reset URL from email + const resetUrl = await getPasswordResetUrl(request, email); + + // Navigate to reset page + await page.goto(resetUrl); + + // Fill in mismatched passwords + await page.getByLabel('Password', { exact: true }).fill('newpassword123'); + await page.getByLabel('Confirm Password').fill('differentpassword456'); + await page.getByRole('button', { name: 'Reset Password' }).click(); + + // Should show validation error + await expect(page.getByText('The password field confirmation does not match.')).toBeVisible(); +}); + +test('shows validation error for short password on reset', async ({ page, request }) => { + // First register a new user + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const originalPassword = 'suchagreatpassword123'; + await registerNewUser(page, email, originalPassword); + + // Log out + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Request password reset + await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password'); + await page.getByLabel('Email').fill(email); + await page.getByRole('button', { name: 'Email Password Reset Link' }).click(); + await expect(page.getByText('We have emailed your password reset link.')).toBeVisible(); + + // Get password reset URL from email + const resetUrl = await getPasswordResetUrl(request, email); + + // Navigate to reset page + await page.goto(resetUrl); + + // Fill in short password + await page.getByLabel('Password', { exact: true }).fill('short'); + await page.getByLabel('Confirm Password').fill('short'); + await page.getByRole('button', { name: 'Reset Password' }).click(); + + // Should show validation error about minimum length + await expect(page.getByText('must be at least')).toBeVisible(); +}); + +test('shows error for invalid login credentials', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/login'); + await page.getByLabel('Email').fill('nonexistent@example.com'); + await page.getByLabel('Password').fill('wrongpassword123'); + await page.getByRole('button', { name: 'Log in' }).click(); + + await expect( + page.getByText('These credentials do not match our records.') + ).toBeVisible(); +}); + +test('shows error when registering with existing email', async ({ page }) => { + const email = `john+${Math.round(Math.random() * 10000)}@doe.com`; + const password = 'suchagreatpassword123'; + + // Register first user + await registerNewUser(page, email, password); + + // Log out + await page.getByTestId('current_user_button').click(); + await page.getByText('Log Out').click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login'); + + // Try to register with the same email + await page.goto(PLAYWRIGHT_BASE_URL + '/register'); + await page.getByLabel('Name').fill('Another User'); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByLabel('Confirm Password').fill(password); + await page.getByLabel('I agree to the Terms of').click(); + await page.getByRole('button', { name: 'Register' }).click(); + + // Should show error about email already taken + await expect(page.getByText('The resource already exists.')).toBeVisible(); +}); + +test('shows validation error for weak password on registration', async ({ page }) => { + await page.goto(PLAYWRIGHT_BASE_URL + '/register'); + await page.getByLabel('Name').fill('Weak Password User'); + await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`); + await page.getByLabel('Password', { exact: true }).fill('short'); + await page.getByLabel('Confirm Password').fill('short'); + await page.getByLabel('I agree to the Terms of').click(); + await page.getByRole('button', { name: 'Register' }).click(); + + await expect(page.getByText('must be at least')).toBeVisible(); +}); diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 71971169..30ea00d0 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -2,7 +2,11 @@ import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; -import { createProject, createBillableProject, createBareTimeEntry } from './utils/reporting'; +import { + createBillableProjectViaApi, + createProjectViaApi, + createBareTimeEntryViaApi, +} from './utils/api'; async function goToCalendar(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/calendar'); @@ -17,11 +21,12 @@ async function goToCalendar(page: Page) { test('test that changing project in calendar edit modal from non-billable to billable updates billable status', async ({ page, + ctx, }) => { const billableProjectName = 'Billable Cal Project ' + Math.floor(1 + Math.random() * 10000); - await createBillableProject(page, billableProjectName); - await createBareTimeEntry(page, 'Test billable calendar', '1h'); + await createBillableProjectViaApi(ctx, { name: billableProjectName }); + await createBareTimeEntryViaApi(ctx, 'Test billable calendar', '1h'); await goToCalendar(page); @@ -59,14 +64,15 @@ test('test that changing project in calendar edit modal from non-billable to bil test('test that changing project in calendar edit modal from billable to non-billable updates billable status', async ({ page, + ctx, }) => { const billableProjectName = 'Billable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); const nonBillableProjectName = 'NonBillable Cal Rev Project ' + Math.floor(1 + Math.random() * 10000); - await createBillableProject(page, billableProjectName); - await createProject(page, nonBillableProjectName); - await createBareTimeEntry(page, 'Test billable cal reverse', '1h'); + await createBillableProjectViaApi(ctx, { name: billableProjectName }); + await createProjectViaApi(ctx, { name: nonBillableProjectName }); + await createBareTimeEntryViaApi(ctx, 'Test billable cal reverse', '1h'); await goToCalendar(page); @@ -112,12 +118,13 @@ test('test that changing project in calendar edit modal from billable to non-bil test('test that opening calendar edit modal for a time entry with manually overridden billable status preserves that status', async ({ page, + ctx, }) => { const billableProjectName = 'Billable Cal Persist Project ' + Math.floor(1 + Math.random() * 10000); - await createBillableProject(page, billableProjectName); - await createBareTimeEntry(page, 'Test cal persist override', '1h'); + await createBillableProjectViaApi(ctx, { name: billableProjectName }); + await createBareTimeEntryViaApi(ctx, 'Test cal persist override', '1h'); await goToCalendar(page); @@ -186,3 +193,98 @@ test('test that opening calendar edit modal for a time entry with manually overr const responseBody = await updateResponse.json(); expect(responseBody.data.billable).toBe(false); }); + +test('test that calendar page loads and displays time entries', async ({ page, ctx }) => { + await createBareTimeEntryViaApi(ctx, 'Calendar display test', '1h'); + + await goToCalendar(page); + + // Calendar container should be visible + await expect(page.locator('.fc')).toBeVisible(); + + // The time entry should appear as a calendar event + await expect( + page.locator('.fc-event').filter({ hasText: 'Calendar display test' }).first() + ).toBeVisible(); +}); + +test('test that calendar navigation buttons work', async ({ page }) => { + await goToCalendar(page); + await expect(page.locator('.fc')).toBeVisible(); + + // Click the "next" button to navigate forward + await page.locator('button.fc-next-button').click(); + await expect(page.locator('.fc')).toBeVisible(); + + // Click the "prev" button to navigate back + await page.locator('button.fc-prev-button').click(); + await expect(page.locator('.fc')).toBeVisible(); + + // Navigate forward first so "today" button becomes enabled, then click it + await page.locator('button.fc-next-button').click(); + await page.locator('button.fc-today-button').click(); + await expect(page.locator('.fc')).toBeVisible(); +}); + +test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { + const originalDescription = 'Edit me in calendar ' + Math.floor(1 + Math.random() * 10000); + const updatedDescription = 'Updated in calendar ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, originalDescription, '1h'); + + await goToCalendar(page); + + // Click on the time entry event + await page.locator('.fc-event').filter({ hasText: originalDescription }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Update the description (edit modal uses placeholder, not data-testid) + const descriptionInput = page.getByRole('dialog').getByPlaceholder('What did you work on?'); + await descriptionInput.fill(updatedDescription); + + // Save and verify + const [editResponse] = await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + page.getByRole('button', { name: 'Update Time Entry' }).click(), + ]); + const editBody = await editResponse.json(); + expect(editBody.data.description).toBe(updatedDescription); + + // Verify the updated description is shown in the calendar UI + await expect( + page.locator('.fc-event').filter({ hasText: updatedDescription }).first() + ).toBeVisible(); + // Verify the old description is no longer shown + await expect( + page.locator('.fc-event').filter({ hasText: originalDescription }) + ).not.toBeVisible(); +}); + +test('test that deleting time entry from calendar modal works', async ({ page, ctx }) => { + const description = 'Delete me from calendar ' + Math.floor(1 + Math.random() * 10000); + await createBareTimeEntryViaApi(ctx, description, '1h'); + + await goToCalendar(page); + + // Click on the time entry event + await page.locator('.fc-event').filter({ hasText: description }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + + // Click the delete button + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/time-entries/') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(), + ]); + + // Verify the event is removed from the calendar + await expect(page.locator('.fc-event').filter({ hasText: description })).not.toBeVisible(); +}); diff --git a/e2e/clients.spec.ts b/e2e/clients.spec.ts index 280f081b..39720e07 100644 --- a/e2e/clients.spec.ts +++ b/e2e/clients.spec.ts @@ -2,15 +2,16 @@ import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import { test } from '../playwright/fixtures'; +import { createClientViaApi } from './utils/api'; -async function goToProjectsOverview(page: Page) { +async function goToClientsOverview(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/clients'); } -// Create new project via modal +// Create new client via modal test('test that creating and deleting a new client via the modal works', async ({ page }) => { const newClientName = 'New Project ' + Math.floor(1 + Math.random() * 10000); - await goToProjectsOverview(page); + await goToClientsOverview(page); await page.getByRole('button', { name: 'Create Client' }).click(); await page.getByPlaceholder('Client Name').fill(newClientName); await Promise.all([ @@ -42,13 +43,11 @@ test('test that creating and deleting a new client via the modal works', async ( await expect(page.getByTestId('client_table')).not.toContainText(newClientName); }); -test('test that archiving and unarchiving clients works', async ({ page }) => { +test('test that archiving and unarchiving clients works', async ({ page, ctx }) => { const newClientName = 'New Client ' + Math.floor(1 + Math.random() * 10000); - await goToProjectsOverview(page); - await page.getByRole('button', { name: 'Create Client' }).click(); - await page.getByLabel('Client Name').fill(newClientName); + await createClientViaApi(ctx, { name: newClientName }); - await page.getByRole('button', { name: 'Create Client' }).click(); + await goToClientsOverview(page); await expect(page.getByText(newClientName)).toBeVisible(); await page.getByRole('row').first().getByRole('button').click(); @@ -72,4 +71,57 @@ test('test that archiving and unarchiving clients works', async ({ page }) => { ]); }); -// TODO: Add Name Update Test +test('test that editing a client name works', async ({ page, ctx }) => { + const originalName = 'Original Client ' + Math.floor(1 + Math.random() * 10000); + const updatedName = 'Updated Client ' + Math.floor(1 + Math.random() * 10000); + await createClientViaApi(ctx, { name: originalName }); + + await goToClientsOverview(page); + await expect(page.getByText(originalName)).toBeVisible(); + + // Open edit modal via actions menu + const moreButton = page.locator("[aria-label='Actions for Client " + originalName + "']"); + await moreButton.click(); + await page.getByTestId('client_edit').click(); + + // Update the client name + await page.getByPlaceholder('Client Name').fill(updatedName); + await Promise.all([ + page.getByRole('button', { name: 'Update Client' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/clients') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + // Verify updated name is shown and old name is gone + await expect(page.getByTestId('client_table')).toContainText(updatedName); + await expect(page.getByTestId('client_table')).not.toContainText(originalName); +}); + +test('test that deleting a client via actions menu works', async ({ page, ctx }) => { + const clientName = 'DeleteMe Client ' + Math.floor(1 + Math.random() * 10000); + + await createClientViaApi(ctx, { name: clientName }); + + await goToClientsOverview(page); + await expect(page.getByTestId('client_table')).toContainText(clientName); + + const moreButton = page.locator("[aria-label='Actions for Client " + clientName + "']"); + await moreButton.click(); + const deleteButton = page.locator("[aria-label='Delete Client " + clientName + "']"); + + await Promise.all([ + deleteButton.click(), + page.waitForResponse( + (response) => + response.url().includes('/clients') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + ]); + + await expect(page.getByTestId('client_table')).not.toContainText(clientName); +}); diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts index 2fcabf65..2f0995be 100644 --- a/e2e/command-palette.spec.ts +++ b/e2e/command-palette.spec.ts @@ -20,8 +20,8 @@ async function closeCommandPalette(page: Page) { async function searchInCommandPalette(page: Page, query: string) { await page.locator('[role="dialog"] input').fill(query); - // Wait for search to filter and API calls to settle - await page.waitForTimeout(500); + // Wait for search debounce to settle (command palette uses a debounced search) + await page.waitForTimeout(300); } async function selectCommand(page: Page, name: string) { @@ -306,26 +306,23 @@ test.describe('Command Palette', () => { await page.goto(PLAYWRIGHT_BASE_URL + '/projects'); await page.getByRole('button', { name: 'Create Project' }).click(); await page.getByPlaceholder('The next big thing').fill(projectName); + await page.getByRole('button', { name: 'Create Project' }).click(); // Wait for project to be created and page to update await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 }); - // Now go to dashboard and search for the project - await goToDashboard(page); + // Search from the projects page where the query cache now has the new project await openCommandPalette(page); await searchInCommandPalette(page, projectName); - // Wait for entity search to return results, then use keyboard to select - await page.waitForTimeout(1500); + // Wait for entity search to return results + const projectOption = page.getByRole('option').filter({ hasText: projectName }); + await expect(projectOption).toBeVisible({ + timeout: 5000, + }); - // Use keyboard to navigate down (past any matching commands) and select - // The project should appear in search results - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('Enter'); - - // Should navigate somewhere (either project page or remain on dashboard) - // If entity search found the project, it will navigate to project page - await page.waitForTimeout(500); + // Select the project from search results + await projectOption.click(); }); }); diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index 0ccfd90d..066889ae 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -4,13 +4,11 @@ import { expect, test } from '../playwright/fixtures'; import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; import type { Page } from '@playwright/test'; -import path from 'path'; -import fs from 'fs'; -import os from 'os'; import { inviteAndAcceptMember } from './utils/members'; +import { createPlaceholderMemberViaImportApi } from './utils/api'; // Tests that invite + accept members need more time -test.describe.configure({ timeout: 60000 }); +test.describe.configure({ timeout: 45000 }); async function goToMembersPage(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/members'); @@ -104,46 +102,11 @@ test('test that organization billable rate can be updated with all existing time ]); }); -async function createPlaceholderMemberViaImport(page: Page, placeholderName: string) { - const placeholderEmail = `placeholder+${Math.floor(Math.random() * 100000)}@solidtime-import.test`; - const csvContent = [ - 'User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Tags', - `${placeholderName},${placeholderEmail},,,,Imported entry,No,2024-01-01,09:00:00,2024-01-01,10:00:00,`, - ].join('\n'); - - // Write CSV to a temp file for upload - const tmpDir = os.tmpdir(); - const tmpFile = path.join(tmpDir, `import-${Date.now()}.csv`); - fs.writeFileSync(tmpFile, csvContent); - - await page.goto(PLAYWRIGHT_BASE_URL + '/import'); - - // Select "Toggl Time Entries" import type - await page.locator('select#importType').selectOption({ label: 'Toggl Time Entries' }); - - // Upload the CSV file - await page.locator('input[type="file"]').setInputFiles(tmpFile); - - // Click Import and wait for success - await Promise.all([ - page.getByRole('button', { name: 'Import Data' }).click(), - page.waitForResponse( - (response) => response.url().includes('/import') && response.status() === 200 - ), - ]); - - // Close the result modal - await page.getByRole('button', { name: 'Close' }).click(); - - // Clean up temp file - fs.unlinkSync(tmpFile); -} - -test('test that changing role of placeholder member is rejected', async ({ page }) => { +test('test that changing role of placeholder member is rejected', async ({ page, ctx }) => { const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000); // Create a placeholder member via import - await createPlaceholderMemberViaImport(page, placeholderName); + await createPlaceholderMemberViaImportApi(ctx, placeholderName); // Go to members page and verify placeholder exists with role "Placeholder" await goToMembersPage(page); @@ -226,11 +189,11 @@ test('test that changing member role updates the role in the member table', asyn await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible(); }); -test('test that merging a placeholder member works', async ({ page }) => { +test('test that merging a placeholder member works', async ({ page, ctx }) => { const placeholderName = 'Merge Target ' + Math.floor(Math.random() * 10000); // Create a placeholder member via import - await createPlaceholderMemberViaImport(page, placeholderName); + await createPlaceholderMemberViaImportApi(ctx, placeholderName); // Go to members page await goToMembersPage(page); @@ -265,9 +228,234 @@ test('test that merging a placeholder member works', async ({ page }) => { ), ]); - // Wait for dialog to close after successful merge - await expect(page.getByRole('dialog')).not.toBeVisible(); + // Wait for merge dialog to close after successful merge + await expect(page.getByRole('dialog').filter({ hasText: 'Merge Member' })).not.toBeVisible(); // Verify placeholder member is no longer in the members table await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible(); }); + +test('test that deleting a placeholder member works', async ({ page, ctx }) => { + const placeholderName = 'Delete Target ' + Math.floor(Math.random() * 10000); + + // Create a placeholder member via import + await createPlaceholderMemberViaImportApi(ctx, placeholderName); + + // Go to members page + await goToMembersPage(page); + const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); + await expect(memberRow).toBeVisible(); + + // Open actions menu and click Delete + await memberRow.getByRole('button').click(); + await page.getByRole('menuitem').getByText('Delete').click(); + + // Verify delete modal is shown + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Delete Member' })).toBeVisible(); + + // Try to delete without checking the confirmation checkbox + await page.getByRole('button', { name: 'Delete Member' }).click(); + + // Should show validation error + await expect( + page.getByText('You must confirm that you understand the consequences of this action') + ).toBeVisible(); + + // Check the confirmation checkbox + await page.getByRole('checkbox').click(); + + // Click Delete Member button and wait for API response + await Promise.all([ + page.getByRole('button', { name: 'Delete Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/members/') && + response.request().method() === 'DELETE' && + response.ok() + ), + ]); + + // Verify modal is closed + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify member is removed from the table + await expect(page.getByRole('main').getByText(placeholderName)).not.toBeVisible(); +}); + +test('test that member delete modal can be cancelled', async ({ page, ctx }) => { + const placeholderName = 'Delete Cancel ' + Math.floor(Math.random() * 10000); + + // Create a placeholder member via import + await createPlaceholderMemberViaImportApi(ctx, placeholderName); + + // Go to members page + await goToMembersPage(page); + const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); + await expect(memberRow).toBeVisible(); + + // Open actions menu and click Delete + await memberRow.getByRole('button').click(); + await page.getByRole('menuitem').getByText('Delete').click(); + + // Verify delete modal is shown + await expect(page.getByRole('dialog')).toBeVisible(); + + // Set up listener to verify no DELETE request is sent + let deleteRequestSent = false; + page.on('request', (request) => { + if (request.url().includes('/members/') && request.method() === 'DELETE') { + deleteRequestSent = true; + } + }); + + // Click Cancel + await page.getByRole('button', { name: 'Cancel' }).click(); + + // Verify modal is closed + await expect(page.getByRole('dialog')).not.toBeVisible(); + + // Verify member is still in the table + await expect(memberRow).toBeVisible(); + + // Verify no DELETE request was sent + expect(deleteRequestSent).toBe(false); +}); + +test('test that organization owner cannot be deleted', async ({ page }) => { + await goToMembersPage(page); + + // Find the owner row (John Doe with Owner role) + const ownerRow = page.getByRole('row').filter({ hasText: 'Owner' }); + await expect(ownerRow).toBeVisible(); + + // Open the actions menu for the owner + await ownerRow.getByRole('button').click(); + + // Click Delete + await page.getByRole('menuitem').getByText('Delete').click(); + + // Verify delete modal is shown + await expect(page.getByRole('dialog')).toBeVisible(); + + // Check the confirmation checkbox + await page.getByRole('checkbox').click(); + + // Try to delete - should fail with 400 error + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/members/') && response.request().method() === 'DELETE' + ); + await page.getByRole('button', { name: 'Delete Member' }).click(); + const response = await responsePromise; + + // Verify the API returned an error status + expect(response.status()).toBe(400); + + // Close the modal by pressing Escape + await page.keyboard.press('Escape'); + + // Refresh and verify the owner is still there + await goToMembersPage(page); + await expect(page.getByRole('row').filter({ hasText: 'Owner' })).toBeVisible(); +}); + +// ============================================= +// Invitations Tab Tests +// ============================================= + +test('test that invitation shows in invitations tab and can be revoked', async ({ page }) => { + const inviteEmail = `invite+${Math.floor(Math.random() * 100000)}@pending.test`; + + await goToMembersPage(page); + await openInviteMemberModal(page); + + await page.getByPlaceholder('Member Email').fill(inviteEmail); + await page.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + page.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + // Wait for modal to close + await expect(page.getByPlaceholder('Member Email')).not.toBeVisible(); + + // Switch to Invitations tab and verify the invitation is visible + await page.getByText('Invitations', { exact: true }).click(); + await expect(page.getByText(inviteEmail)).toBeVisible(); + + // Find and click the actions menu for this invitation + const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail }); + await invitationRow.getByRole('button').click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations/') && + response.request().method() === 'DELETE' && + response.status() === 204 + ), + page.getByRole('menuitem').getByText('Delete').click(), + ]); + + // Verify invitation is removed + await expect(page.getByText(inviteEmail)).not.toBeVisible(); +}); + +test('test that invitation can be resent', async ({ page }) => { + const inviteEmail = `resend+${Math.floor(Math.random() * 100000)}@invite.test`; + + await goToMembersPage(page); + await openInviteMemberModal(page); + + await page.getByPlaceholder('Member Email').fill(inviteEmail); + await page.getByRole('button', { name: 'Employee' }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/invitations') && + response.request().method() === 'POST' && + response.status() === 204 + ), + page.getByRole('button', { name: 'Invite Member', exact: true }).click(), + ]); + + // Wait for modal to close + await expect(page.getByPlaceholder('Member Email')).not.toBeVisible(); + + // Switch to Invitations tab + await page.getByText('Invitations', { exact: true }).click(); + await expect(page.getByText(inviteEmail)).toBeVisible(); + + // Find and click the actions menu, then resend + const invitationRow = page.locator('tr, [role="row"]').filter({ hasText: inviteEmail }); + await invitationRow.getByRole('button').click(); + // Wait for dropdown menu to appear + await expect(page.getByRole('menuitem').getByText('Resend Invitation')).toBeVisible(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/resend') && response.request().method() === 'POST' + ), + page.getByRole('menuitem').getByText('Resend Invitation').click(), + ]); +}); + +test('test that accepted invitation disappears from invitations tab', async ({ page, browser }) => { + const memberId = Math.round(Math.random() * 100000); + const memberEmail = `accepted+${memberId}@invite.test`; + + // Invite and accept the member + await inviteAndAcceptMember(page, browser, 'Accepted Member', memberEmail, 'Employee'); + + // Go to members page and switch to Invitations tab + await goToMembersPage(page); + await page.getByRole('tab', { name: 'Invitations' }).click(); + + // The accepted invitation should not be visible + await expect(page.getByText(memberEmail)).not.toBeVisible(); +}); diff --git a/e2e/organization.spec.ts b/e2e/organization.spec.ts index 78541c25..69e7d5ca 100644 --- a/e2e/organization.spec.ts +++ b/e2e/organization.spec.ts @@ -228,4 +228,139 @@ test('test that format settings are reflected in the dashboard', async ({ page } ).toBeVisible(); }); -// TODO: Test 12-hour clock format +test('test that organization time entry settings can be toggled', async ({ page }) => { + await goToOrganizationSettings(page); + + const preventOverlappingCheckbox = page.getByLabel( + 'Prevent overlapping time entries (new entries only)' + ); + const manageTasksCheckbox = page.getByLabel('Allow Employees to manage tasks'); + + // Get current states and toggle both + const wasOverlappingChecked = await preventOverlappingCheckbox.isChecked(); + const wasManageTasksChecked = await manageTasksCheckbox.isChecked(); + + if (wasOverlappingChecked) { + await preventOverlappingCheckbox.uncheck(); + } else { + await preventOverlappingCheckbox.check(); + } + + if (wasManageTasksChecked) { + await manageTasksCheckbox.uncheck(); + } else { + await manageTasksCheckbox.check(); + } + + // Save + const settingsForm = page.locator('form').filter({ hasText: 'Prevent overlapping' }); + await Promise.all([ + settingsForm.getByRole('button', { name: 'Save' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.prevent_overlapping_time_entries === + !wasOverlappingChecked + ), + ]); + + // Reload and verify both settings persisted + await page.reload(); + await expect(preventOverlappingCheckbox).toBeChecked({ checked: !wasOverlappingChecked }); + await expect(manageTasksCheckbox).toBeChecked({ checked: !wasManageTasksChecked }); + + // Toggle both back to restore original state + if (!wasOverlappingChecked) { + await preventOverlappingCheckbox.uncheck(); + } else { + await preventOverlappingCheckbox.check(); + } + + if (!wasManageTasksChecked) { + await manageTasksCheckbox.uncheck(); + } else { + await manageTasksCheckbox.check(); + } + + await Promise.all([ + settingsForm.getByRole('button', { name: 'Save' }).click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.prevent_overlapping_time_entries === + wasOverlappingChecked + ), + ]); +}); + +test('test that 12-hour clock format can be set', async ({ page }) => { + await goToOrganizationSettings(page); + + await page.getByLabel('Time Format').click(); + await page.getByRole('option', { name: '12-hour clock' }).click(); + await Promise.all([ + page + .locator('form') + .filter({ hasText: 'Time Format' }) + .getByRole('button', { name: 'Save' }) + .click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.time_format === '12-hours' + ), + ]); + + // Reload and verify it persisted + await page.reload(); + await expect(page.getByLabel('Time Format')).toContainText('12-hour clock'); + + // Reset back to 24-hour + await page.getByLabel('Time Format').click(); + await page.getByRole('option', { name: '24-hour clock' }).click(); + await Promise.all([ + page + .locator('form') + .filter({ hasText: 'Time Format' }) + .getByRole('button', { name: 'Save' }) + .click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 && + (await response.json()).data.time_format === '24-hours' + ), + ]); +}); + +test('test that format settings persist after page reload', async ({ page }) => { + await goToOrganizationSettings(page); + + // Set a specific date format + await page.getByLabel('Date Format').click(); + await page.getByRole('option', { name: 'DD/MM/YYYY' }).click(); + await Promise.all([ + page + .locator('form') + .filter({ hasText: 'Date Format' }) + .getByRole('button', { name: 'Save' }) + .click(), + page.waitForResponse( + async (response) => + response.url().includes('/organizations/') && + response.request().method() === 'PUT' && + response.status() === 200 + ), + ]); + + // Reload and verify it persisted + await page.reload(); + await expect(page.getByLabel('Date Format')).toContainText('DD/MM/YYYY'); +}); diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts index a2f34388..81aed742 100644 --- a/e2e/profile.spec.ts +++ b/e2e/profile.spec.ts @@ -1,5 +1,10 @@ import { test, expect } from '../playwright/fixtures'; -import { PLAYWRIGHT_BASE_URL } from '../playwright/config'; +import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config'; +import type { Page } from '@playwright/test'; + +async function goToProfilePage(page: Page) { + await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); +} test('test that user name can be updated', async ({ page }) => { await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile'); @@ -68,3 +73,254 @@ test('test that user can revoke an API key', async ({ page }) => { await expect(page.locator('body')).toContainText('NEW API KEY'); await expect(page.locator('body')).toContainText('Revoked'); }); + +// ============================================= +// Update Password Form Tests +// ============================================= + +test('test that password mismatch shows error', async ({ page }) => { + await goToProfilePage(page); + + // Fill in with mismatched passwords + await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); + await page.getByLabel('New Password').fill('newSecurePassword456'); + await page.getByLabel('Confirm Password').fill('differentPassword789'); + + // Find the form containing the Confirm Password field and click its Save button + const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/user/password') && response.request().method() === 'PUT' + ), + passwordForm.getByRole('button', { name: 'Save' }).click(), + ]); + + // Verify error message about password confirmation + await expect(page.getByText('confirmation does not match')).toBeVisible(); +}); + +test('test that short password shows validation error', async ({ page }) => { + await goToProfilePage(page); + + // Fill in with a too short password + await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); + await page.getByLabel('New Password').fill('short'); + await page.getByLabel('Confirm Password').fill('short'); + + // Find the form containing the Confirm Password field and click its Save button + const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/user/password') && response.request().method() === 'PUT' + ), + passwordForm.getByRole('button', { name: 'Save' }).click(), + ]); + + // Verify error message about password length + await expect(page.getByText('must be at least')).toBeVisible(); +}); + +test('test that incorrect current password shows validation error', async ({ page }) => { + await goToProfilePage(page); + + // Fill in with wrong current password + await page.getByLabel('Current Password').fill('wrongCurrentPassword123'); + await page.getByLabel('New Password').fill('newSecurePassword456'); + await page.getByLabel('Confirm Password').fill('newSecurePassword456'); + + // Find the form containing the Confirm Password field and click its Save button + const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes('/user/password') && response.request().method() === 'PUT' + ), + passwordForm.getByRole('button', { name: 'Save' }).click(), + ]); + + // Verify error message about incorrect password + await expect(page.getByText('does not match')).toBeVisible(); +}); + +test('test that password can be updated successfully', async ({ page }) => { + await goToProfilePage(page); + const newPassword = 'newSecurePassword456'; + + // Change password to new password + await page.getByLabel('Current Password').fill(TEST_USER_PASSWORD); + await page.getByLabel('New Password').fill(newPassword); + await page.getByLabel('Confirm Password').fill(newPassword); + + const passwordForm = page.getByLabel('Confirm Password').locator('xpath=ancestor::form'); + const responsePromise = page.waitForResponse( + (response) => + response.url().includes('/user/password') && response.request().method() === 'PUT' + ); + await passwordForm.getByRole('button', { name: 'Save' }).click(); + const response = await responsePromise; + + // Verify successful response (303 is Inertia redirect on success, means password was updated) + expect(response.status()).toBe(303); + + // Verify no error messages are displayed + await expect(page.getByText('does not match')).not.toBeVisible(); + await expect(page.getByText('must be at least')).not.toBeVisible(); +}); + +// ============================================= +// Theme Selection Tests +// ============================================= + +test('test that theme can be changed to dark and light', async ({ page }) => { + await goToProfilePage(page); + + // The theme select is a Reka UI combobox (button), not a native