Skip to content

Commit 4033041

Browse files
committed
Add Mailpit for email verification and strengthen E2E email assertions
Infrastructure: - Add Mailpit (axllent/mailpit:v1.29) to docker-compose.yml as an actively maintained replacement for Mailhog - Configure Spring dev profile to route all outgoing mail to Mailpit (SMTP on port 1025, web UI on port 8025) E2E email testing (client/e2e/mailpit.ts): - Add Mailpit API helper with snapshot-based email verification that is safe for parallel test execution (no mailbox clearing) - Helpers: snapshotMailbox, waitForNewMessages, getSubject, getBody, getToAddresses, getFromHeader, hasAttachment, assertSentFromApp - Mailpit returns pre-decoded HTML/Text bodies, eliminating the need for manual quoted-printable and base64 MIME decoding Bug fix (ApplicationService.java): - Pass the notifyUser flag through to ThesisService.createThesis() when accepting an application. Previously hardcoded to false, which meant the THESIS_CREATED email was never sent during acceptance even when the advisor checked "Notify Student". E2E workflow tests with email assertions: - application-workflow: verify student confirmation email with topic title, applicant email, and motivation text - application-review-workflow: verify rejection email (topic title, requirements reason) and acceptance emails (APPLICATION_ACCEPTED + THESIS_CREATED with advisor mention and thesis link) - proposal-feedback-workflow: verify proposal upload notification to advisor (with conditional attachment check) and change request email to student (both change requests listed, thesis link) - presentation-workflow: serial test — student creates draft, then supervisor accepts it and scheduled presentation email is verified (greeting, thesis title, location, language, thesis link) - thesis-workflow: verify THESIS_CREATED email to student (greeting, thesis title, supervisor name, proposal next step) - thesis-grading-workflow: verify no assessment email is sent to the submitting supervisor (sender-exclusion logic), verify final grade email to student (grade value, feedback text, supervisor name) Server tests: - Add ApplicationServiceTest cases verifying notifyUser is forwarded to createThesis (eq(true) and eq(false) matchers instead of anyBoolean) - Fix ApplicationControllerTest integration tests to set up the THESIS_CREATED email template for acceptance flows with notifyUser=true Documentation: - Update DEVELOPMENT.md and MAILS.md to reference Mailpit
1 parent 276d2fa commit 4033041

24 files changed

Lines changed: 832 additions & 155 deletions

client/e2e/application-review-workflow.spec.ts

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { test, expect } from '@playwright/test'
22
import { authStatePath, navigateToDetail, selectOption } from './helpers'
3+
import {
4+
snapshotMailbox,
5+
waitForNewMessages,
6+
getBody,
7+
getToAddresses,
8+
assertSentFromApp,
9+
findBySubject,
10+
} from './mailpit'
311

412
const APPLICATION_REJECT_ID = '00000000-0000-4000-c000-000000000004' // student4 on topic 1, NOT_ASSESSED
513
const APPLICATION_ACCEPT_ID = '00000000-0000-4000-c000-000000000005' // student5 on topic 2, NOT_ASSESSED
@@ -14,7 +22,6 @@ test.describe('Application Review Workflow', () => {
1422
if (!loaded) return // Application not accessible (may have been modified by a parallel test)
1523

1624
// Check if application still has the review form (NOT_ASSESSED state)
17-
// A prior test run may have rejected this application and DB wasn't re-seeded
1825
const thesisTitle = page.getByLabel('Thesis Title')
1926
const hasReviewForm = await thesisTitle.isVisible({ timeout: 5_000 }).catch(() => false)
2027
if (!hasReviewForm) {
@@ -28,24 +35,43 @@ test.describe('Application Review Workflow', () => {
2835

2936
// Modal should open with "Reject Application" title
3037
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 })
31-
await expect(
32-
page.getByRole('dialog').getByText('Reject Application').first(),
33-
).toBeVisible()
38+
await expect(page.getByRole('dialog').getByText('Reject Application').first()).toBeVisible()
3439

35-
// "Topic requirements not met" should be the default selected reason for topic-based applications
40+
// "Topic requirements not met" should be the default selected reason
3641
await expect(page.getByText('Topic requirements not met')).toBeVisible()
3742

3843
// "Notify Student" checkbox should be checked by default
3944
const notifyCheckbox = page.getByRole('dialog').getByLabel('Notify Student')
4045
await expect(notifyCheckbox).toBeChecked()
4146

47+
// Snapshot mailbox before the action
48+
const beforeIds = await snapshotMailbox('student4@test.local')
49+
4250
// Click "Reject Application" button in the modal
4351
await page.getByRole('dialog').getByRole('button', { name: 'Reject Application' }).click()
4452

4553
// Verify success notification
4654
await expect(page.getByText('Application rejected successfully')).toBeVisible({
4755
timeout: 10_000,
4856
})
57+
58+
// --- Email verification ---
59+
// Rejection with "Notify Student" sends APPLICATION_REJECTED_TOPIC_REQUIREMENTS
60+
const newEmails = await waitForNewMessages('student4@test.local', beforeIds)
61+
expect(newEmails.length).toBeGreaterThanOrEqual(1)
62+
63+
const rejectionEmail = findBySubject(newEmails, 'Thesis Application Rejection')
64+
expect(rejectionEmail, 'Rejection email with correct subject should be sent').toBeDefined()
65+
assertSentFromApp(rejectionEmail!)
66+
expect(getToAddresses(rejectionEmail!)).toContain('student4@test.local')
67+
68+
// Body should greet the student by first name and reference the topic title
69+
const body = getBody(rejectionEmail!)
70+
expect(body, 'Rejection email should greet the student').toContain('Student4')
71+
expect(body, 'Rejection email should reference the topic title').toContain(
72+
'Automated Code Review Using Large Language Models',
73+
)
74+
expect(body, 'Rejection email should mention requirements').toContain('requirements')
4975
})
5076

5177
test('advisor can accept a NOT_ASSESSED application', async ({ page }) => {
@@ -54,7 +80,6 @@ test.describe('Application Review Workflow', () => {
5480
if (!loaded) return // Application not accessible
5581

5682
// Check if application still has the review form (NOT_ASSESSED state)
57-
// A prior test run may have accepted this application and DB wasn't re-seeded
5883
const thesisTitle = page.getByLabel('Thesis Title')
5984
const hasReviewForm = await thesisTitle.isVisible({ timeout: 5_000 }).catch(() => false)
6085
if (!hasReviewForm) {
@@ -63,8 +88,6 @@ test.describe('Application Review Workflow', () => {
6388

6489
// Verify the acceptance form has pre-filled fields from the topic
6590
await expect(thesisTitle).not.toHaveValue('')
66-
67-
// Thesis Type should be pre-filled
6891
await expect(page.getByRole('textbox', { name: 'Thesis Type' })).toBeVisible()
6992

7093
// Thesis Language may not be pre-filled — fill it if empty
@@ -89,6 +112,9 @@ test.describe('Application Review Workflow', () => {
89112
timeout: 10_000,
90113
})
91114

115+
// Snapshot mailbox for student5 before the action
116+
const beforeIds = await snapshotMailbox('student5@test.local')
117+
92118
// Click "Accept" button
93119
const acceptButton = page.getByRole('button', { name: 'Accept', exact: true })
94120
await expect(acceptButton).toBeEnabled({ timeout: 10_000 })
@@ -98,5 +124,34 @@ test.describe('Application Review Workflow', () => {
98124
await expect(page.getByText('Application accepted successfully')).toBeVisible({
99125
timeout: 30_000,
100126
})
127+
128+
// --- Email verification ---
129+
// Acceptance sends APPLICATION_ACCEPTED + THESIS_CREATED both to student5
130+
const newEmails = await waitForNewMessages('student5@test.local', beforeIds, 2, 30_000)
131+
132+
// Verify acceptance email was sent to student5 (APPLICATION_ACCEPTED template)
133+
const acceptanceEmail = findBySubject(newEmails, 'Thesis Application Acceptance')
134+
expect(acceptanceEmail, 'Acceptance email should be sent').toBeDefined()
135+
assertSentFromApp(acceptanceEmail!)
136+
expect(getToAddresses(acceptanceEmail!)).toContain('student5@test.local')
137+
138+
// Body should greet student, mention the advisor, and include a thesis link
139+
const acceptBody = getBody(acceptanceEmail!)
140+
expect(acceptBody, 'Should greet the student by first name').toContain('Student5')
141+
expect(acceptBody, 'Should mention the advisor for coordination').toContain('advisor')
142+
expect(acceptBody, 'Should contain a link to the thesis').toContain('/theses/')
143+
144+
// Verify thesis creation email was also sent to student5 (THESIS_CREATED template)
145+
const thesisEmail = findBySubject(newEmails, 'Thesis Created')
146+
expect(thesisEmail, 'Thesis creation email should be sent').toBeDefined()
147+
assertSentFromApp(thesisEmail!)
148+
expect(getToAddresses(thesisEmail!)).toContain('student5@test.local')
149+
150+
// Body should contain the thesis title and a link to the thesis
151+
const thesisBody = getBody(thesisEmail!)
152+
expect(thesisBody, 'Should contain the thesis title').toContain(
153+
'Continuous Integration Pipeline Optimization',
154+
)
155+
expect(thesisBody, 'Should contain a link to the thesis').toContain('/theses/')
101156
})
102157
})

client/e2e/application-workflow.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import {
66
navigateTo,
77
selectOption,
88
} from './helpers'
9+
import {
10+
snapshotMailbox,
11+
waitForNewMessages,
12+
getBody,
13+
getToAddresses,
14+
assertSentFromApp,
15+
findBySubject,
16+
} from './mailpit'
917

1018
test.describe('Application Workflow - Student submits application', () => {
1119
test.use({ storageState: authStatePath('student') })
@@ -89,12 +97,39 @@ test.describe('Application Workflow - Student submits application', () => {
8997
'I am highly motivated to work on CI pipeline optimization because it aligns with my research interests in DevOps and continuous integration.',
9098
)
9199

100+
// Snapshot mailbox BEFORE submitting (safe for parallel execution)
101+
const beforeIds = await snapshotMailbox('student@test.local')
102+
92103
const submitButton = page.getByRole('button', { name: 'Submit Application' })
93104
await expect(submitButton).toBeEnabled({ timeout: 10_000 })
94105
await submitButton.click()
95106

96107
// Verify we're still on the application page (no crash)
97108
await page.waitForTimeout(2_000)
98109
await expect(page).toHaveURL(/\/submit-application/)
110+
111+
// --- Email verification ---
112+
// Application submission sends:
113+
// 1. APPLICATION_CREATED_STUDENT → student (confirmation with application details)
114+
// 2. APPLICATION_CREATED_CHAIR → research group members (optional, depends on notification preferences)
115+
const newEmails = await waitForNewMessages('student@test.local', beforeIds)
116+
117+
// Verify student confirmation email (APPLICATION_CREATED_STUDENT template)
118+
const studentEmail = findBySubject(newEmails, 'Thesis Application Confirmation')
119+
expect(studentEmail, 'Student confirmation email should be sent').toBeDefined()
120+
assertSentFromApp(studentEmail!)
121+
expect(getToAddresses(studentEmail!)).toContain('student@test.local')
122+
123+
// Body should contain: greeting, topic title, applicant details, and motivation
124+
const studentBody = getBody(studentEmail!)
125+
expect(studentBody, 'Body should greet the student by first name').toContain('Student')
126+
expect(studentBody, 'Body should contain the topic/thesis title').toContain(
127+
'Continuous Integration Pipeline Optimization',
128+
)
129+
expect(studentBody, 'Body should contain applicant email').toContain('student@test.local')
130+
expect(studentBody, 'Body should reference the motivation text').toContain('CI pipeline')
131+
132+
// Note: APPLICATION_CREATED_CHAIR may also be sent to research group members,
133+
// but it is filtered by notification preferences and goes to different recipients.
99134
})
100135
})

client/e2e/applications.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ test.describe('Applications - Student', () => {
2222

2323
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible({ timeout: 30_000 })
2424
// Student should see My Applications section
25-
await expect(page.getByRole('heading', { name: /my applications/i })).toBeVisible({ timeout: 15_000 })
25+
await expect(page.getByRole('heading', { name: /my applications/i })).toBeVisible({
26+
timeout: 15_000,
27+
})
2628
})
2729
})
2830

client/e2e/auth.setup.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ const TEST_USERS = [
1010
{ name: 'supervisor2', username: 'supervisor2', password: 'supervisor2' },
1111
{ name: 'admin', username: 'admin', password: 'admin' },
1212
{ name: 'delete_old_thesis', username: 'delete_old_thesis', password: 'delete_old_thesis' },
13-
{ name: 'delete_recent_thesis', username: 'delete_recent_thesis', password: 'delete_recent_thesis' },
13+
{
14+
name: 'delete_recent_thesis',
15+
username: 'delete_recent_thesis',
16+
password: 'delete_recent_thesis',
17+
},
1418
{ name: 'delete_rejected_app', username: 'delete_rejected_app', password: 'delete_rejected_app' },
1519
] as const
1620

@@ -31,16 +35,19 @@ for (const user of TEST_USERS) {
3135
await expect(page).toHaveURL(/localhost:3000/, { timeout: 30_000 })
3236

3337
// Wait for the app to fully initialize with the auth tokens
34-
await page.waitForFunction(() => {
35-
try {
36-
const tokens = localStorage.getItem('authentication_tokens')
37-
if (!tokens) return false
38-
const parsed = JSON.parse(tokens)
39-
return !!parsed.access_token && !!parsed.refresh_token
40-
} catch {
41-
return false
42-
}
43-
}, { timeout: 15_000 })
38+
await page.waitForFunction(
39+
() => {
40+
try {
41+
const tokens = localStorage.getItem('authentication_tokens')
42+
if (!tokens) return false
43+
const parsed = JSON.parse(tokens)
44+
return !!parsed.access_token && !!parsed.refresh_token
45+
} catch {
46+
return false
47+
}
48+
},
49+
{ timeout: 15_000 },
50+
)
4451

4552
// Save the authenticated state (localStorage + cookies including Keycloak session)
4653
await page.context().storageState({ path: `e2e/.auth/${user.name}.json` })

client/e2e/auth.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ test.describe('Authentication - Unauthenticated', () => {
2626
test('about page is accessible without login', async ({ page }) => {
2727
await navigateTo(page, '/about')
2828

29-
await expect(page.getByRole('heading', { name: 'Thesis Management', exact: true })).toBeVisible()
29+
await expect(
30+
page.getByRole('heading', { name: 'Thesis Management', exact: true }),
31+
).toBeVisible()
3032
})
3133
})
3234

client/e2e/interview-workflow.spec.ts

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,15 @@ test.describe('Interview Workflow', () => {
99

1010
test('supervisor can view interview process and score an interviewee', async ({ page }) => {
1111
// Navigate directly to the interviewee assessment page
12-
await navigateTo(
13-
page,
14-
`/interviews/${INTERVIEW_PROCESS_ID}/interviewee/${INTERVIEWEE_ID}`,
15-
)
12+
await navigateTo(page, `/interviews/${INTERVIEW_PROCESS_ID}/interviewee/${INTERVIEWEE_ID}`)
1613

1714
// Wait for page to load with the interviewee name
18-
await expect(
19-
page.getByRole('heading', { name: /Interview - Student4 User/i }),
20-
).toBeVisible({ timeout: 15_000 })
15+
await expect(page.getByRole('heading', { name: /Interview - Student4 User/i })).toBeVisible({
16+
timeout: 15_000,
17+
})
2118

2219
// Verify "Interview Assessment" section title
23-
await expect(
24-
page.getByRole('heading', { name: 'Interview Assessment' }),
25-
).toBeVisible()
20+
await expect(page.getByRole('heading', { name: 'Interview Assessment' })).toBeVisible()
2621

2722
// Verify "Score" card title is visible
2823
await expect(page.getByRole('heading', { name: 'Score', exact: true })).toBeVisible()
@@ -31,13 +26,13 @@ test.describe('Interview Workflow', () => {
3126
await page.getByText('Great Candidate').click()
3227

3328
// Verify score selection registered — Mantine SegmentedControl sets data-active on the label
34-
const scoreLabel = page.getByText('Great Candidate').locator('xpath=ancestor::label[@data-active]')
29+
const scoreLabel = page
30+
.getByText('Great Candidate')
31+
.locator('xpath=ancestor::label[@data-active]')
3532
await expect(scoreLabel).toBeVisible({ timeout: 5_000 })
3633

3734
// Verify "Interview Note" card title is visible
38-
await expect(
39-
page.getByRole('heading', { name: 'Interview Note', exact: true }),
40-
).toBeVisible()
35+
await expect(page.getByRole('heading', { name: 'Interview Note', exact: true })).toBeVisible()
4136

4237
// Fill interview note using the ProseMirror editor
4338
// (InterviewNoteCard uses DocumentEditor without a Mantine InputWrapper label,
@@ -46,7 +41,9 @@ test.describe('Interview Workflow', () => {
4641
await noteEditor.click()
4742
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
4843
await page.keyboard.press(`${modifier}+a`)
49-
await page.keyboard.type('Good understanding of streaming concepts and anomaly detection methods.')
44+
await page.keyboard.type(
45+
'Good understanding of streaming concepts and anomaly detection methods.',
46+
)
5047

5148
// Wait for auto-save (debounced at 1000ms) — look for "Saved" indicator
5249
await expect(page.getByText('Saved')).toBeVisible({ timeout: 10_000 })
@@ -56,14 +53,14 @@ test.describe('Interview Workflow', () => {
5653
await navigateTo(page, `/interviews/${INTERVIEW_PROCESS_ID}`)
5754

5855
// Wait for interview management page to load
59-
await expect(
60-
page.getByRole('heading', { name: /interview management/i }),
61-
).toBeVisible({ timeout: 15_000 })
56+
await expect(page.getByRole('heading', { name: /interview management/i })).toBeVisible({
57+
timeout: 15_000,
58+
})
6259

6360
// Verify interviewees section is visible
64-
await expect(
65-
page.getByRole('heading', { name: 'Interviewees', exact: true }),
66-
).toBeVisible({ timeout: 10_000 })
61+
await expect(page.getByRole('heading', { name: 'Interviewees', exact: true })).toBeVisible({
62+
timeout: 10_000,
63+
})
6764

6865
// Click "Add Slot" button to open the modal
6966
await page.getByRole('button', { name: /Add Slot|^Add$/i }).click()

client/e2e/interviews.spec.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,23 @@ test.describe('Interviews - Supervisor', () => {
77
test('interview overview shows interview topics and upcoming interviews', async ({ page }) => {
88
await navigateTo(page, '/interviews')
99

10-
await expect(page.getByRole('heading', { name: 'Interviews', exact: true })).toBeVisible({ timeout: 30_000 })
10+
await expect(page.getByRole('heading', { name: 'Interviews', exact: true })).toBeVisible({
11+
timeout: 30_000,
12+
})
1113
// Should show the two panels
1214
await expect(page.getByText(/interview topics/i)).toBeVisible()
13-
await expect(page.getByRole('heading', { name: 'Upcoming Interviews', exact: true })).toBeVisible()
15+
await expect(
16+
page.getByRole('heading', { name: 'Upcoming Interviews', exact: true }),
17+
).toBeVisible()
1418
})
1519

1620
test('interview process detail page loads', async ({ page }) => {
1721
// UUID matches the active interview process seeded in seed_dev_test_data.sql
1822
await navigateTo(page, '/interviews/00000000-0000-4000-e600-000000000001')
1923

20-
await expect(page.getByRole('heading', { name: /interview management/i })).toBeVisible({ timeout: 30_000 })
24+
await expect(page.getByRole('heading', { name: /interview management/i })).toBeVisible({
25+
timeout: 30_000,
26+
})
2127
// Should show sections for slots and interviewees
2228
await expect(page.getByRole('heading', { name: 'Interviewees', exact: true })).toBeVisible()
2329
})
@@ -29,7 +35,9 @@ test.describe('Interviews - Advisor', () => {
2935
test('interview overview is accessible', async ({ page }) => {
3036
await navigateTo(page, '/interviews')
3137

32-
await expect(page.getByRole('heading', { name: 'Interviews', exact: true })).toBeVisible({ timeout: 15_000 })
38+
await expect(page.getByRole('heading', { name: 'Interviews', exact: true })).toBeVisible({
39+
timeout: 15_000,
40+
})
3341
await expect(page.getByText(/interview topics/i)).toBeVisible()
3442
})
3543
})

0 commit comments

Comments
 (0)