From 9379c191be4b1896b15dd2873f0e678dae3353c1 Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 2 Feb 2026 16:06:33 +0100 Subject: [PATCH] Add Mailpit SMTP and refine Playwright tests --- .env.ci | 7 +- .github/workflows/playwright.yml | 4 ++ docker-compose.yml | 2 +- e2e/members.spec.ts | 118 ++++++++++++++++++++++--------- e2e/project-members.spec.ts | 2 +- e2e/reporting.spec.ts | 8 +-- e2e/time.spec.ts | 3 +- e2e/utils/mailpit.ts | 53 ++++++++++++++ e2e/utils/members.ts | 68 ++++++++++++++++++ playwright/config.ts | 1 + 10 files changed, 226 insertions(+), 40 deletions(-) create mode 100644 e2e/utils/mailpit.ts create mode 100644 e2e/utils/members.ts diff --git a/.env.ci b/.env.ci index 40ce2768..e1039743 100644 --- a/.env.ci +++ b/.env.ci @@ -34,7 +34,12 @@ SESSION_DRIVER=database SESSION_LIFETIME=120 # Mail -MAIL_MAILER=log +MAIL_MAILER=smtp +MAIL_HOST=localhost +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_NAME="solidtime" MAIL_REPLY_TO_ADDRESS="hello@solidtime.test" diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index d0f9b805..98432491 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -10,6 +10,9 @@ jobs: services: mailpit: image: 'axllent/mailpit:latest' + ports: + - 1025:1025 + - 8025:8025 pgsql_test: image: postgres:15 env: @@ -67,6 +70,7 @@ jobs: run: npx playwright test env: PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000' + MAILPIT_BASE_URL: 'http://localhost:8025' - name: "Upload test results" uses: actions/upload-artifact@v4 diff --git a/docker-compose.yml b/docker-compose.yml index 53d73ed4..99148a2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -107,7 +107,7 @@ services: - sail - reverse-proxy playwright: - image: mcr.microsoft.com/playwright:v1.51.1-jammy + image: mcr.microsoft.com/playwright:v1.58.1-jammy command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] working_dir: /src extra_hosts: diff --git a/e2e/members.spec.ts b/e2e/members.spec.ts index 7f182811..0ccfd90d 100644 --- a/e2e/members.spec.ts +++ b/e2e/members.spec.ts @@ -7,6 +7,10 @@ import type { Page } from '@playwright/test'; import path from 'path'; import fs from 'fs'; import os from 'os'; +import { inviteAndAcceptMember } from './utils/members'; + +// Tests that invite + accept members need more time +test.describe.configure({ timeout: 60000 }); async function goToMembersPage(page: Page) { await page.goto(PLAYWRIGHT_BASE_URL + '/members'); @@ -19,41 +23,45 @@ async function openInviteMemberModal(page: Page) { ]); } -test('test that new manager can be invited', async ({ page }) => { +test('test that new manager can be invited and accepted', async ({ page, browser }) => { + const memberId = Math.round(Math.random() * 100000); + const memberEmail = `manager+${memberId}@invite.test`; + + await inviteAndAcceptMember(page, browser, 'Invited Mgr', memberEmail, 'Manager'); + + // Verify the member appears in the members table with the correct role await goToMembersPage(page); - await openInviteMemberModal(page); - const editorId = Math.round(Math.random() * 10000); - await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); - await page.getByRole('button', { name: 'Manager' }).click(); - await Promise.all([ - page.getByRole('button', { name: 'Invite Member', exact: true }).click(), - expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`), - ]); + const memberRow = page.getByRole('row').filter({ hasText: 'Invited Mgr' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible(); }); -test('test that new employee can be invited', async ({ page }) => { +test('test that new employee can be invited and accepted', async ({ page, browser }) => { + const memberId = Math.round(Math.random() * 100000); + const memberEmail = `employee+${memberId}@invite.test`; + + await inviteAndAcceptMember(page, browser, 'Invited Emp', memberEmail, 'Employee'); + + // Verify the member appears in the members table with the correct role await goToMembersPage(page); - await openInviteMemberModal(page); - const editorId = Math.round(Math.random() * 10000); - await page.getByLabel('Email').fill(`new+${editorId}@editor.test`); - await page.getByRole('button', { name: 'Employee' }).click(); - await Promise.all([ - page.getByRole('button', { name: 'Invite Member', exact: true }).click(), - expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`), - ]); + const memberRow = page.getByRole('row').filter({ hasText: 'Invited Emp' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible(); }); -test('test that new admin can be invited', async ({ page }) => { +test('test that new admin can be invited and accepted', async ({ page, browser }) => { + const memberId = Math.round(Math.random() * 100000); + const memberEmail = `admin+${memberId}@invite.test`; + + await inviteAndAcceptMember(page, browser, 'Invited Adm', memberEmail, 'Administrator'); + + // Verify the member appears in the members table with the correct role await goToMembersPage(page); - await openInviteMemberModal(page); - const adminId = Math.round(Math.random() * 10000); - await page.getByLabel('Email').fill(`new+${adminId}@admin.test`); - await page.getByRole('button', { name: 'Administrator' }).click(); - await Promise.all([ - page.getByRole('button', { name: 'Invite Member', exact: true }).click(), - expect(page.getByRole('main')).toContainText(`new+${adminId}@admin.test`), - ]); + const memberRow = page.getByRole('row').filter({ hasText: 'Invited Adm' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Admin', { exact: true })).toBeVisible(); }); + test('test that error shows if no role is selected', async ({ page }) => { await goToMembersPage(page); await openInviteMemberModal(page); @@ -131,7 +139,7 @@ async function createPlaceholderMemberViaImport(page: Page, placeholderName: str fs.unlinkSync(tmpFile); } -test('test that changing member role updates the role in the member table', async ({ page }) => { +test('test that changing role of placeholder member is rejected', async ({ page }) => { const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000); // Create a placeholder member via import @@ -141,7 +149,7 @@ test('test that changing member role updates the role in the member table', asyn await goToMembersPage(page); const memberRow = page.getByRole('row').filter({ hasText: placeholderName }); await expect(memberRow).toBeVisible(); - await expect(memberRow.getByText('Placeholder')).toBeVisible(); + await expect(memberRow.getByText('Placeholder', { exact: true })).toBeVisible(); // Open the edit modal for the placeholder member await memberRow.getByRole('button').click(); @@ -152,7 +160,53 @@ test('test that changing member role updates the role in the member table', asyn // Change role to Employee const roleSelect = page.getByRole('dialog').getByRole('combobox').first(); await roleSelect.click(); + await expect(page.getByRole('option', { name: 'Employee' })).toBeVisible(); await page.getByRole('option', { name: 'Employee' }).click(); + await expect(roleSelect).toContainText('Employee'); + + // Submit the change - the API should reject it with 400 + await Promise.all([ + page.getByRole('button', { name: 'Update Member' }).click(), + page.waitForResponse( + (response) => + response.url().includes('/members/') && + response.request().method() === 'PUT' && + response.status() === 400 + ), + ]); + + // Verify error notification is shown + await expect(page.getByText('Failed to update member')).toBeVisible(); +}); + +test('test that changing member role updates the role in the member table', async ({ + page, + browser, +}) => { + const memberId = Math.floor(Math.random() * 100000); + const memberEmail = `member+${memberId}@rolechange.test`; + + // Invite and accept a new Employee member + await inviteAndAcceptMember(page, browser, 'Jane Smith', memberEmail, 'Employee'); + + // Verify the new member appears with the Employee role + await goToMembersPage(page); + const memberRow = page.getByRole('row').filter({ hasText: 'Jane Smith' }); + await expect(memberRow).toBeVisible(); + await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible(); + + // Open the edit modal + await memberRow.getByRole('button').click(); + await page.getByRole('menuitem').getByText('Edit').click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible(); + + // Change role to Manager + const roleSelect = page.getByRole('dialog').getByRole('combobox').first(); + await roleSelect.click(); + await expect(page.getByRole('option', { name: 'Manager' })).toBeVisible(); + await page.getByRole('option', { name: 'Manager' }).click(); + await expect(roleSelect).toContainText('Manager'); // Submit the change and verify the API call succeeds await Promise.all([ @@ -169,7 +223,7 @@ test('test that changing member role updates the role in the member table', asyn await expect(page.getByRole('dialog')).not.toBeVisible(); // Verify the role updated in the table - await expect(memberRow.getByText('Employee')).toBeVisible(); + await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible(); }); test('test that merging a placeholder member works', async ({ page }) => { @@ -192,8 +246,8 @@ test('test that merging a placeholder member works', async ({ page }) => { await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible(); // Select the current user (the owner) as merge target via MemberCombobox - const combobox = page.getByRole('dialog').getByRole('combobox'); - await combobox.click(); + // The MemberCombobox renders a Button as trigger; clicking it opens the popover with the combobox input + await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click(); // Wait for dropdown options to load const firstOption = page.getByRole('option').first(); diff --git a/e2e/project-members.spec.ts b/e2e/project-members.spec.ts index 20fe201f..aff8ff81 100644 --- a/e2e/project-members.spec.ts +++ b/e2e/project-members.spec.ts @@ -32,7 +32,7 @@ test('test that updating project member billable rate works for existing time en await page.getByRole('button', { name: 'Add Member' }).click(); await expect(page.getByText('Add Project Member').first()).toBeVisible(); - await page.getByRole('combobox').filter({ hasText: 'Select a member' }).click(); + await page.getByRole('button', { name: 'Select a member...' }).click(); await page.getByRole('option').first().click(); await page.getByRole('button', { name: 'Add Project Member' }).click(); diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index 02a1825a..edf6e1fd 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -189,7 +189,7 @@ test('test that deselecting a project removes the filter', async ({ page }) => { // Deselect project await page.getByRole('button', { name: 'Projects' }).first().click(); await page.getByRole('option').filter({ hasText: project1 }).click(); - await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + await page.keyboard.press('Escape'); // Verify badge count is gone (no count displayed when 0) await expect( @@ -283,7 +283,7 @@ test('test that deselecting a client removes the filter', async ({ page }) => { // Deselect client await page.getByRole('button', { name: 'Clients' }).first().click(); await page.getByRole('option').filter({ hasText: client1 }).click(); - await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + await page.keyboard.press('Escape'); await expect( page.getByRole('button', { name: 'Clients' }).first().getByText(/^\d+$/) @@ -414,7 +414,7 @@ test('test that deselecting a member removes the filter', async ({ page }) => { // Deselect member await page.getByRole('button', { name: 'Members' }).first().click(); await page.getByRole('option').filter({ hasText: 'John Doe' }).click(); - await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + await page.keyboard.press('Escape'); // Verify badge count is gone await expect( @@ -506,7 +506,7 @@ test('test that deselecting a tag removes the filter', async ({ page }) => { // Deselect tag await page.getByRole('button', { name: 'Tags' }).click(); await page.getByRole('option').filter({ hasText: tag1 }).click(); - await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]); + await page.keyboard.press('Escape'); await expect(page.getByRole('button', { name: 'Tags' }).getByText(/^\d+$/)).not.toBeVisible(); }); diff --git a/e2e/time.spec.ts b/e2e/time.spec.ts index a5ab5443..8d2cb027 100644 --- a/e2e/time.spec.ts +++ b/e2e/time.spec.ts @@ -392,7 +392,8 @@ test('test that mass update billable status works', async ({ page }) => { page.getByRole('button', { name: 'Update Time Entries' }).click(), ]); const massUpdateBody = await massUpdateResponse.json(); - expect(massUpdateBody.data.billable).toBe(true); + expect(massUpdateBody.success.length).toBeGreaterThan(0); + expect(massUpdateBody.error.length).toBe(0); // Verify dialog closes await expect(page.getByRole('dialog')).not.toBeVisible(); diff --git a/e2e/utils/mailpit.ts b/e2e/utils/mailpit.ts new file mode 100644 index 00000000..27d54d35 --- /dev/null +++ b/e2e/utils/mailpit.ts @@ -0,0 +1,53 @@ +import { expect } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import { MAILPIT_BASE_URL } from '../../playwright/config'; + +/** + * Search for emails in Mailpit matching the given query. + */ +export async function searchEmails( + request: APIRequestContext, + query: string +): Promise<{ messages: Array<{ ID: string; Subject: string }> }> { + const response = await request.get(`${MAILPIT_BASE_URL}/api/v1/search?query=${query}`); + return response.json(); +} + +/** + * Get the full email message from Mailpit by ID. + */ +export async function getMessage( + request: APIRequestContext, + messageId: string +): Promise<{ HTML: string; Text: string }> { + const response = await request.get(`${MAILPIT_BASE_URL}/api/v1/message/${messageId}`); + return response.json(); +} + +/** + * Find the invitation acceptance URL from a Mailpit email sent to the given address. + * Retries a few times to allow for email delivery delay. + */ +export async function getInvitationAcceptUrl( + request: APIRequestContext, + recipientEmail: string +): Promise { + let searchResult: { messages: Array<{ ID: string }> } = { messages: [] }; + + // Retry up to 5 times with 500ms delay to allow for email delivery + for (let attempt = 0; attempt < 5; attempt++) { + searchResult = await searchEmails( + request, + `to:${encodeURIComponent(recipientEmail)} subject:"Organization Invitation"` + ); + if (searchResult.messages.length > 0) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + expect(searchResult.messages.length).toBeGreaterThan(0); + + const message = await getMessage(request, searchResult.messages[0].ID); + const acceptUrlMatch = message.HTML.match(/href="([^"]*team-invitations[^"]*)"/); + expect(acceptUrlMatch).toBeTruthy(); + + return acceptUrlMatch![1].replace(/&/g, '&'); +} diff --git a/e2e/utils/members.ts b/e2e/utils/members.ts new file mode 100644 index 00000000..7effa8ef --- /dev/null +++ b/e2e/utils/members.ts @@ -0,0 +1,68 @@ +import { expect } from '@playwright/test'; +import type { Browser, Page } from '@playwright/test'; +import { PLAYWRIGHT_BASE_URL } from '../../playwright/config'; +import { getInvitationAcceptUrl } from './mailpit'; + +/** + * Register a new user in a fresh browser context and return the page + context. + */ +export async function registerUser( + browser: Browser, + name: string, + email: string +): Promise<{ page: Page; close: () => Promise }> { + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.goto(PLAYWRIGHT_BASE_URL + '/register'); + await page.getByLabel('Name').fill(name); + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password', { exact: true }).fill('amazingpassword123'); + await page.getByLabel('Confirm Password').fill('amazingpassword123'); + await page.getByLabel('I agree to the Terms of').click(); + await page.getByRole('button', { name: 'Register' }).click(); + await page.waitForURL(PLAYWRIGHT_BASE_URL + '/dashboard'); + + return { page, close: () => context.close() }; +} + +/** + * Invite a user by email from the members page and accept the invitation + * through a second browser session, returning the accepted member to the + * members table as a real (non-placeholder) member. + * + * @param ownerPage – The page of the organization owner who sends the invite + * @param browser – Browser instance used to create a second context + * @param memberName – Display name for the new user + * @param memberEmail – Email address (must not be registered yet) + * @param role – Role button label: 'Employee' | 'Manager' | 'Administrator' + */ +export async function inviteAndAcceptMember( + ownerPage: Page, + browser: Browser, + memberName: string, + memberEmail: string, + role: 'Employee' | 'Manager' | 'Administrator' +): Promise { + // 1. Register the second user + const secondUser = await registerUser(browser, memberName, memberEmail); + + // 2. Send invitation from the owner + await ownerPage.goto(PLAYWRIGHT_BASE_URL + '/members'); + await ownerPage.getByRole('button', { name: 'Invite Member' }).click(); + await expect(ownerPage.getByPlaceholder('Member Email')).toBeVisible(); + await ownerPage.getByLabel('Email').fill(memberEmail); + await ownerPage.getByRole('button', { name: role }).click(); + await Promise.all([ + ownerPage.getByRole('button', { name: 'Invite Member', exact: true }).click(), + expect(ownerPage.getByRole('main')).toContainText(memberEmail), + ]); + + // 3. Retrieve the acceptance link from Mailpit and accept + const acceptUrl = await getInvitationAcceptUrl(secondUser.page.request, memberEmail); + await secondUser.page.goto(acceptUrl); + await secondUser.page.waitForURL(/dashboard/); + + // 4. Clean up + await secondUser.close(); +} diff --git a/playwright/config.ts b/playwright/config.ts index 3d12852a..d2c0c280 100644 --- a/playwright/config.ts +++ b/playwright/config.ts @@ -1 +1,2 @@ export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test'; +export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';