Skip to content

Commit 47deb51

Browse files
committed
Add Mailpit SMTP and refine Playwright tests
1 parent 98634f4 commit 47deb51

File tree

8 files changed

+105
-40
lines changed

8 files changed

+105
-40
lines changed

.env.ci

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@ SESSION_DRIVER=database
3434
SESSION_LIFETIME=120
3535

3636
# Mail
37-
MAIL_MAILER=log
37+
MAIL_MAILER=smtp
38+
MAIL_HOST=localhost
39+
MAIL_PORT=1025
40+
MAIL_USERNAME=null
41+
MAIL_PASSWORD=null
42+
MAIL_ENCRYPTION=null
3843
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
3944
MAIL_FROM_NAME="solidtime"
4045
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"

.github/workflows/playwright.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ jobs:
1010
services:
1111
mailpit:
1212
image: 'axllent/mailpit:latest'
13+
ports:
14+
- 1025:1025
15+
- 8025:8025
1316
pgsql_test:
1417
image: postgres:15
1518
env:
@@ -67,6 +70,7 @@ jobs:
6770
run: npx playwright test
6871
env:
6972
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
73+
MAILPIT_BASE_URL: 'http://localhost:8025'
7074

7175
- name: "Upload test results"
7276
uses: actions/upload-artifact@v4

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ services:
107107
- sail
108108
- reverse-proxy
109109
playwright:
110-
image: mcr.microsoft.com/playwright:v1.51.1-jammy
110+
image: mcr.microsoft.com/playwright:v1.58.1-jammy
111111
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
112112
working_dir: /src
113113
extra_hosts:

e2e/members.spec.ts

Lines changed: 86 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import type { Page } from '@playwright/test';
77
import path from 'path';
88
import fs from 'fs';
99
import os from 'os';
10+
import { inviteAndAcceptMember } from './utils/members';
11+
12+
// Tests that invite + accept members need more time
13+
test.describe.configure({ timeout: 60000 });
1014

1115
async function goToMembersPage(page: Page) {
1216
await page.goto(PLAYWRIGHT_BASE_URL + '/members');
@@ -19,41 +23,45 @@ async function openInviteMemberModal(page: Page) {
1923
]);
2024
}
2125

22-
test('test that new manager can be invited', async ({ page }) => {
26+
test('test that new manager can be invited and accepted', async ({ page, browser }) => {
27+
const memberId = Math.round(Math.random() * 100000);
28+
const memberEmail = `manager+${memberId}@invite.test`;
29+
30+
await inviteAndAcceptMember(page, browser, 'Invited Mgr', memberEmail, 'Manager');
31+
32+
// Verify the member appears in the members table with the correct role
2333
await goToMembersPage(page);
24-
await openInviteMemberModal(page);
25-
const editorId = Math.round(Math.random() * 10000);
26-
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
27-
await page.getByRole('button', { name: 'Manager' }).click();
28-
await Promise.all([
29-
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
30-
expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
31-
]);
34+
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Mgr' });
35+
await expect(memberRow).toBeVisible();
36+
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
3237
});
3338

34-
test('test that new employee can be invited', async ({ page }) => {
39+
test('test that new employee can be invited and accepted', async ({ page, browser }) => {
40+
const memberId = Math.round(Math.random() * 100000);
41+
const memberEmail = `employee+${memberId}@invite.test`;
42+
43+
await inviteAndAcceptMember(page, browser, 'Invited Emp', memberEmail, 'Employee');
44+
45+
// Verify the member appears in the members table with the correct role
3546
await goToMembersPage(page);
36-
await openInviteMemberModal(page);
37-
const editorId = Math.round(Math.random() * 10000);
38-
await page.getByLabel('Email').fill(`new+${editorId}@editor.test`);
39-
await page.getByRole('button', { name: 'Employee' }).click();
40-
await Promise.all([
41-
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
42-
expect(page.getByRole('main')).toContainText(`new+${editorId}@editor.test`),
43-
]);
47+
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Emp' });
48+
await expect(memberRow).toBeVisible();
49+
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
4450
});
4551

46-
test('test that new admin can be invited', async ({ page }) => {
52+
test('test that new admin can be invited and accepted', async ({ page, browser }) => {
53+
const memberId = Math.round(Math.random() * 100000);
54+
const memberEmail = `admin+${memberId}@invite.test`;
55+
56+
await inviteAndAcceptMember(page, browser, 'Invited Adm', memberEmail, 'Administrator');
57+
58+
// Verify the member appears in the members table with the correct role
4759
await goToMembersPage(page);
48-
await openInviteMemberModal(page);
49-
const adminId = Math.round(Math.random() * 10000);
50-
await page.getByLabel('Email').fill(`new+${adminId}@admin.test`);
51-
await page.getByRole('button', { name: 'Administrator' }).click();
52-
await Promise.all([
53-
page.getByRole('button', { name: 'Invite Member', exact: true }).click(),
54-
expect(page.getByRole('main')).toContainText(`new+${adminId}@admin.test`),
55-
]);
60+
const memberRow = page.getByRole('row').filter({ hasText: 'Invited Adm' });
61+
await expect(memberRow).toBeVisible();
62+
await expect(memberRow.getByText('Admin', { exact: true })).toBeVisible();
5663
});
64+
5765
test('test that error shows if no role is selected', async ({ page }) => {
5866
await goToMembersPage(page);
5967
await openInviteMemberModal(page);
@@ -131,7 +139,7 @@ async function createPlaceholderMemberViaImport(page: Page, placeholderName: str
131139
fs.unlinkSync(tmpFile);
132140
}
133141

134-
test('test that changing member role updates the role in the member table', async ({ page }) => {
142+
test('test that changing role of placeholder member is rejected', async ({ page }) => {
135143
const placeholderName = 'RoleChange ' + Math.floor(Math.random() * 10000);
136144

137145
// Create a placeholder member via import
@@ -141,7 +149,7 @@ test('test that changing member role updates the role in the member table', asyn
141149
await goToMembersPage(page);
142150
const memberRow = page.getByRole('row').filter({ hasText: placeholderName });
143151
await expect(memberRow).toBeVisible();
144-
await expect(memberRow.getByText('Placeholder')).toBeVisible();
152+
await expect(memberRow.getByText('Placeholder', { exact: true })).toBeVisible();
145153

146154
// Open the edit modal for the placeholder member
147155
await memberRow.getByRole('button').click();
@@ -152,7 +160,53 @@ test('test that changing member role updates the role in the member table', asyn
152160
// Change role to Employee
153161
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
154162
await roleSelect.click();
163+
await expect(page.getByRole('option', { name: 'Employee' })).toBeVisible();
155164
await page.getByRole('option', { name: 'Employee' }).click();
165+
await expect(roleSelect).toContainText('Employee');
166+
167+
// Submit the change - the API should reject it with 400
168+
await Promise.all([
169+
page.getByRole('button', { name: 'Update Member' }).click(),
170+
page.waitForResponse(
171+
(response) =>
172+
response.url().includes('/members/') &&
173+
response.request().method() === 'PUT' &&
174+
response.status() === 400
175+
),
176+
]);
177+
178+
// Verify error notification is shown
179+
await expect(page.getByText('Failed to update member')).toBeVisible();
180+
});
181+
182+
test('test that changing member role updates the role in the member table', async ({
183+
page,
184+
browser,
185+
}) => {
186+
const memberId = Math.floor(Math.random() * 100000);
187+
const memberEmail = `member+${memberId}@rolechange.test`;
188+
189+
// Invite and accept a new Employee member
190+
await inviteAndAcceptMember(page, browser, 'Jane Smith', memberEmail, 'Employee');
191+
192+
// Verify the new member appears with the Employee role
193+
await goToMembersPage(page);
194+
const memberRow = page.getByRole('row').filter({ hasText: 'Jane Smith' });
195+
await expect(memberRow).toBeVisible();
196+
await expect(memberRow.getByText('Employee', { exact: true })).toBeVisible();
197+
198+
// Open the edit modal
199+
await memberRow.getByRole('button').click();
200+
await page.getByRole('menuitem').getByText('Edit').click();
201+
await expect(page.getByRole('dialog')).toBeVisible();
202+
await expect(page.getByRole('heading', { name: 'Update Member' })).toBeVisible();
203+
204+
// Change role to Manager
205+
const roleSelect = page.getByRole('dialog').getByRole('combobox').first();
206+
await roleSelect.click();
207+
await expect(page.getByRole('option', { name: 'Manager' })).toBeVisible();
208+
await page.getByRole('option', { name: 'Manager' }).click();
209+
await expect(roleSelect).toContainText('Manager');
156210

157211
// Submit the change and verify the API call succeeds
158212
await Promise.all([
@@ -169,7 +223,7 @@ test('test that changing member role updates the role in the member table', asyn
169223
await expect(page.getByRole('dialog')).not.toBeVisible();
170224

171225
// Verify the role updated in the table
172-
await expect(memberRow.getByText('Employee')).toBeVisible();
226+
await expect(memberRow.getByText('Manager', { exact: true })).toBeVisible();
173227
});
174228

175229
test('test that merging a placeholder member works', async ({ page }) => {
@@ -192,8 +246,8 @@ test('test that merging a placeholder member works', async ({ page }) => {
192246
await expect(page.getByRole('heading', { name: 'Merge Member' })).toBeVisible();
193247

194248
// Select the current user (the owner) as merge target via MemberCombobox
195-
const combobox = page.getByRole('dialog').getByRole('combobox');
196-
await combobox.click();
249+
// The MemberCombobox renders a Button as trigger; clicking it opens the popover with the combobox input
250+
await page.getByRole('dialog').getByRole('button', { name: 'Select a member...' }).click();
197251

198252
// Wait for dropdown options to load
199253
const firstOption = page.getByRole('option').first();

e2e/project-members.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ test('test that updating project member billable rate works for existing time en
3232
await page.getByRole('button', { name: 'Add Member' }).click();
3333

3434
await expect(page.getByText('Add Project Member').first()).toBeVisible();
35-
await page.getByRole('combobox').filter({ hasText: 'Select a member' }).click();
35+
await page.getByRole('button', { name: 'Select a member...' }).click();
3636
await page.getByRole('option').first().click();
3737
await page.getByRole('button', { name: 'Add Project Member' }).click();
3838

e2e/reporting.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ test('test that deselecting a project removes the filter', async ({ page }) => {
189189
// Deselect project
190190
await page.getByRole('button', { name: 'Projects' }).first().click();
191191
await page.getByRole('option').filter({ hasText: project1 }).click();
192-
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
192+
await page.keyboard.press('Escape');
193193

194194
// Verify badge count is gone (no count displayed when 0)
195195
await expect(
@@ -283,7 +283,7 @@ test('test that deselecting a client removes the filter', async ({ page }) => {
283283
// Deselect client
284284
await page.getByRole('button', { name: 'Clients' }).first().click();
285285
await page.getByRole('option').filter({ hasText: client1 }).click();
286-
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
286+
await page.keyboard.press('Escape');
287287

288288
await expect(
289289
page.getByRole('button', { name: 'Clients' }).first().getByText(/^\d+$/)
@@ -414,7 +414,7 @@ test('test that deselecting a member removes the filter', async ({ page }) => {
414414
// Deselect member
415415
await page.getByRole('button', { name: 'Members' }).first().click();
416416
await page.getByRole('option').filter({ hasText: 'John Doe' }).click();
417-
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
417+
await page.keyboard.press('Escape');
418418

419419
// Verify badge count is gone
420420
await expect(
@@ -506,7 +506,7 @@ test('test that deselecting a tag removes the filter', async ({ page }) => {
506506
// Deselect tag
507507
await page.getByRole('button', { name: 'Tags' }).click();
508508
await page.getByRole('option').filter({ hasText: tag1 }).click();
509-
await Promise.all([page.keyboard.press('Escape'), waitForReportingUpdate(page)]);
509+
await page.keyboard.press('Escape');
510510

511511
await expect(page.getByRole('button', { name: 'Tags' }).getByText(/^\d+$/)).not.toBeVisible();
512512
});

e2e/time.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,8 @@ test('test that mass update billable status works', async ({ page }) => {
392392
page.getByRole('button', { name: 'Update Time Entries' }).click(),
393393
]);
394394
const massUpdateBody = await massUpdateResponse.json();
395-
expect(massUpdateBody.data.billable).toBe(true);
395+
expect(massUpdateBody.success.length).toBeGreaterThan(0);
396+
expect(massUpdateBody.error.length).toBe(0);
396397

397398
// Verify dialog closes
398399
await expect(page.getByRole('dialog')).not.toBeVisible();

playwright/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
2+
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';

0 commit comments

Comments
 (0)