mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-05-07 20:32:26 +00:00
Add Mailpit SMTP and refine Playwright tests
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(/&/g, '&');
|
||||
}
|
||||
@@ -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 +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';
|
||||
|
||||
Reference in New Issue
Block a user