Add Mailpit SMTP and refine Playwright tests

This commit is contained in:
Gregor Vostrak
2026-02-02 16:06:33 +01:00
parent ff06d4d2f3
commit 9379c191be
10 changed files with 226 additions and 40 deletions
+6 -1
View File
@@ -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"
+4
View File
@@ -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
+1 -1
View File
@@ -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:
+86 -32
View File
@@ -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();
+1 -1
View File
@@ -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();
+4 -4
View File
@@ -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();
});
+2 -1
View File
@@ -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();
+53
View File
@@ -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<string> {
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(/&amp;/g, '&');
}
+68
View File
@@ -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<void> }> {
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<void> {
// 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();
}
+1
View File
@@ -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';