@@ -23,12 +23,9 @@ const JOB_TITLE = 'Frontend Developer — All Fields Test'
2323const JOB_DESCRIPTION = 'Join our team building modern web applications with Vue and Nuxt.'
2424const JOB_LOCATION = 'Berlin, Germany'
2525
26- const APPLICANT = {
27- firstName : 'Jane' ,
28- lastName : 'Doe' ,
29- email : 'jane.doe@example.com' ,
30- phone : '+49 170 1234567' ,
31- }
26+ // Applicant identity is generated inside the test to guarantee uniqueness
27+ // across retries (Playwright can retry up to 2× in CI; a static email would
28+ // produce a 409 "already applied" on the second attempt).
3229
3330/**
3431 * One question per field type defined in QuestionForm.vue / DynamicField.vue.
@@ -139,7 +136,7 @@ async function addCustomQuestion(
139136}
140137
141138test . describe ( 'Candidate Application Flow — All Custom Question Field Types' , ( ) => {
142- test ( 'all nine custom field types render and accept input on the public application form' , async ( { authenticatedPage, browser } ) => {
139+ test ( 'all nine custom field types render and accept input on the public application form' , async ( { authenticatedPage, browser } , testInfo ) => {
143140 const page = authenticatedPage
144141
145142 // ── Step 1: Fill in job details ───────────────────────────────────────────
@@ -199,15 +196,25 @@ test.describe('Candidate Application Flow — All Custom Question Field Types',
199196 // Read the application link from the readonly input in the success card.
200197 // The link has the form: https://<host>/jobs/<slug>/apply
201198 const applicationLink = await page . locator ( 'input[readonly]' ) . inputValue ( )
202- expect ( applicationLink ) . toContain ( '/jobs/' )
203- const jobSlug = applicationLink . split ( '/jobs/' ) [ 1 ] ?. split ( '/apply' ) [ 0 ] ?? ''
199+ expect ( applicationLink ) . toMatch ( / \/ j o b s \/ [ ^ / ] + \/ a p p l y (?: $ | [ ? # ] ) / )
200+ const slugMatch = applicationLink . match ( / \/ j o b s \/ ( [ ^ / ] + ) \/ a p p l y (?: $ | [ ? # ] ) / )
201+ const jobSlug = slugMatch ?. [ 1 ] ?? ''
204202 expect ( jobSlug . length , 'Job slug must not be empty' ) . toBeGreaterThan ( 0 )
205203
206204 // ── Candidate flow: fresh unauthenticated context ─────────────────────────
207205
208206 const candidateContext = await browser . newContext ( )
209207 const candidatePage = await candidateContext . newPage ( )
210208
209+ // Unique identity per run + retry — static emails cause a 409 "already
210+ // applied" conflict when Playwright retries a failed test in CI.
211+ const APPLICANT = {
212+ firstName : 'Jane' ,
213+ lastName : 'Doe' ,
214+ email : `jane.doe.${ Date . now ( ) } .r${ testInfo . retry } @example.com` ,
215+ phone : '+49 170 1234567' ,
216+ }
217+
211218 // Navigate directly to the application form URL captured from the success state
212219 await candidatePage . goto ( applicationLink )
213220 await candidatePage . waitForLoadState ( 'networkidle' )
@@ -256,16 +263,22 @@ test.describe('Candidate Application Flow — All Custom Question Field Types',
256263 await expect ( candidatePage . getByLabel ( 'Agree to background check' ) ) . toBeChecked ( )
257264
258265 // 9. file_upload — hidden <input type="file"> triggered by a styled button.
259- // Because requireResume was disabled, the only file input belongs to this
260- // custom question. Supply a minimal valid PDF buffer .
266+ // Scope to the specific custom question container so the selector remains
267+ // stable even if a built-in resume upload is added later .
261268 const pdfBuffer = Buffer . from (
262269 '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n'
263270 + '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n'
264271 + '3 0 obj<</Type/Page/MediaBox[0 0 612 792]>>endobj\n'
265272 + 'xref\n0 4\n0000000000 65535 f\n'
266273 + 'trailer<</Size 4/Root 1 0 R>>\nstartxref\n9\n%%EOF' ,
267274 )
268- await candidatePage . locator ( 'input[type="file"]' ) . setInputFiles ( {
275+ // Locate the DynamicField wrapper for the "Cover letter document" question
276+ // and narrow the file input to that scope.
277+ const coverLetterQuestionContainer = candidatePage
278+ . locator ( 'div, section, fieldset' )
279+ . filter ( { hasText : 'Cover letter document' } )
280+ . first ( )
281+ await coverLetterQuestionContainer . locator ( 'input[type="file"]' ) . setInputFiles ( {
269282 name : 'cover-letter.pdf' ,
270283 mimeType : 'application/pdf' ,
271284 buffer : pdfBuffer ,
@@ -455,3 +468,79 @@ test.describe('Candidate Application Flow — All Custom Question Field Types',
455468 await expect ( page . getByText ( 'https://github.com/jane-doe' ) . first ( ) ) . toBeVisible ( ) // url
456469 } )
457470} )
471+
472+ test . describe ( 'Candidate Application — Required Cover Letter Validation' , ( ) => {
473+ /**
474+ * Verifies that:
475+ * - The cover letter textarea appears when the job has requireCoverLetter=true
476+ * - Client-side validation blocks submission when the textarea is empty
477+ * - The error message "Cover letter is required" is displayed
478+ */
479+ test ( 'form shows and enforces required cover letter' , async ( { authenticatedPage, browser } , testInfo ) => {
480+ const page = authenticatedPage
481+
482+ // ── Create a job with cover letter required ────────────────────────────────
483+ await page . goto ( '/dashboard/jobs/new' )
484+ await page . waitForLoadState ( 'networkidle' )
485+ await page . getByLabel ( 'Job title' ) . waitFor ( { state : 'visible' , timeout : 15_000 } )
486+ await page . getByLabel ( 'Job title' ) . fill ( 'Cover Letter Required Job' )
487+ await page . locator ( 'textarea' ) . first ( ) . fill ( 'A job that requires a cover letter.' )
488+ await page . getByLabel ( 'Location' ) . fill ( 'Remote' )
489+
490+ // Step 1 → Step 2
491+ await page . locator ( 'form' ) . getByRole ( 'button' , { name : 'Save & continue' } ) . first ( )
492+ . waitFor ( { state : 'attached' , timeout : 10_000 } )
493+ await page . locator ( 'form' ) . getByRole ( 'button' , { name : 'Save & continue' } ) . first ( ) . click ( )
494+
495+ // Step 2: Enable "Ask for cover letter" toggle
496+ const coverLetterToggle = page . getByRole ( 'button' , { name : / A s k f o r c o v e r l e t t e r / i } )
497+ await coverLetterToggle . waitFor ( { state : 'visible' , timeout : 10_000 } )
498+ if ( ( await coverLetterToggle . getAttribute ( 'aria-pressed' ) ) !== 'true' ) {
499+ await coverLetterToggle . click ( )
500+ }
501+ await expect ( coverLetterToggle ) . toHaveAttribute ( 'aria-pressed' , 'true' )
502+
503+ // Step 2 → Step 3 → Step 4
504+ await page . locator ( 'form' ) . getByRole ( 'button' , { name : 'Save & continue' } ) . first ( ) . click ( )
505+ await page . locator ( 'form' ) . getByRole ( 'button' , { name : 'Save & continue' } ) . first ( )
506+ . waitFor ( { state : 'visible' , timeout : 10_000 } )
507+ await page . locator ( 'form' ) . getByRole ( 'button' , { name : 'Save & continue' } ) . first ( ) . click ( )
508+ await expect ( page . getByRole ( 'heading' , { name : / R e a d y t o g o \? / i } ) ) . toBeVisible ( { timeout : 10_000 } )
509+ await page . locator ( 'form' ) . getByRole ( 'button' , { name : / P u b l i s h & c o p y l i n k / i } )
510+ . waitFor ( { state : 'visible' , timeout : 10_000 } )
511+ await page . locator ( 'form' ) . getByRole ( 'button' , { name : / P u b l i s h & c o p y l i n k / i } ) . click ( )
512+ await expect ( page . getByRole ( 'heading' , { name : 'Your job is live!' } ) ) . toBeVisible ( { timeout : 20_000 } )
513+
514+ // Capture the application link
515+ const applicationLink = await page . locator ( 'input[readonly]' ) . inputValue ( )
516+ expect ( applicationLink ) . toMatch ( / \/ j o b s \/ [ ^ / ] + \/ a p p l y (?: $ | [ ? # ] ) / )
517+
518+ // ── Candidate flow ────────────────────────────────────────────────────────
519+ const candidateContext = await browser . newContext ( )
520+ const candidatePage = await candidateContext . newPage ( )
521+
522+ await candidatePage . goto ( applicationLink )
523+ await candidatePage . waitForLoadState ( 'networkidle' )
524+ await candidatePage . getByRole ( 'button' , { name : / s u b m i t / i } ) . waitFor ( { state : 'visible' , timeout : 15_000 } )
525+
526+ // The cover letter textarea must be visible (requireCoverLetter=true)
527+ await expect ( candidatePage . locator ( '#coverLetterText' ) ) . toBeVisible ( { timeout : 10_000 } )
528+
529+ // Fill required basic fields but leave cover letter EMPTY
530+ await candidatePage . getByLabel ( 'First name' ) . fill ( 'Test' )
531+ await candidatePage . getByLabel ( 'Last name' ) . fill ( 'Applicant' )
532+ await candidatePage . getByLabel ( 'Email' ) . fill ( `test.applicant.${ Date . now ( ) } .r${ testInfo . retry } @example.com` )
533+
534+ // Submit without cover letter — client validation must block it
535+ await candidatePage . getByRole ( 'button' , { name : / s u b m i t / i } ) . click ( )
536+
537+ // Error message should appear; the page should NOT navigate
538+ await expect (
539+ candidatePage . getByText ( / C o v e r l e t t e r i s r e q u i r e d / i) ,
540+ ) . toBeVisible ( { timeout : 5_000 } )
541+ expect ( candidatePage . url ( ) ) . not . toContain ( '/confirmation' )
542+
543+ await candidatePage . close ( )
544+ await candidateContext . close ( )
545+ } )
546+ } )
0 commit comments