import React from 'react'; import '@testing-library/jest-dom'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ConfirmProvider } from './confirm'; import { PromptProvider } from './prompt'; import { ToastProvider } from './toast'; import { I18nProvider } from './i18n/I18nProvider'; import JobDetailsDialog from './components/JobDetailsDialog'; import { api } from './api'; jest.setTimeout(15000); jest.mock('./api', () => ({ api: { get: jest.fn(), post: jest.fn(), put: jest.fn(), patch: jest.fn(), delete: jest.fn(), interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }, })); const mockedApi = api as jest.Mocked; function renderDialog() { return render( {}} /> , ); } beforeEach(() => { Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue(undefined), }, }); Object.assign(URL, { createObjectURL: jest.fn().mockReturnValue('blob:preview-pdf'), revokeObjectURL: jest.fn(), }); mockedApi.get.mockImplementation((url: string) => { if (url === '/jobapplications/42') { return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' }, tailoredCvText: 'Saved CV', coverLetterText: 'Saved cover letter', recruiterMessageDraft: 'Saved recruiter message', notes: 'Original notes\n\n<<>>\nSaved application answer\n<<>>', shortSummary: 'summary' } } as any); } if (url === '/auth/me') { return Promise.resolve({ data: { roles: [], avatarImageDataUrl: 'data:image/png;base64,avatar123' } } as any); } if (url === '/jobapplications/42/history') { return Promise.resolve({ data: [] } as any); } if (url === '/attachments/42') { return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any); } if (url === '/jobapplications/42/tailored-cv-draft') { return Promise.resolve({ data: { id: 5, canonicalProfileVersion: 3, templateId: 'ats-minimal', headline: 'Backend Engineer', summary: ['Built APIs', 'Shipped backend work'], selectedSkills: ['.NET', 'SQL'], experience: [], education: [], customSections: [], renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' }, generationContextHash: 'abc123', lastGeneratedAtUtc: new Date().toISOString(), lastEditedAtUtc: null, status: 'generated', renderedText: 'Backend Engineer\n\nProfessional Summary\n- Built APIs\n- Shipped backend work', isLegacyFallback: false, } } as any); } if (url === '/jobapplications/42/candidate-fit') { return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any); } if (url === '/jobapplications/42/focus-plan') { return Promise.resolve({ data: { strategicSummary: 'Lead with backend delivery and measurable outcomes.', immediatePriorities: ['Highlight .NET ownership'], cvBulletIdeas: [], proofPointsToLeadWith: [], coverLetterAngles: [], followUpApproach: [] } } as any); } return Promise.resolve({ data: [] } as any); }); mockedApi.post.mockImplementation((url: string, body?: any, config?: any) => { if (url === '/jobapplications/42/generate-tailored-cv-draft') { return Promise.resolve({ data: { id: 5, canonicalProfileVersion: 3, templateId: 'ats-minimal', headline: 'Senior Backend Engineer', summary: ['Owned API delivery', 'Improved SQL workflows'], selectedSkills: ['.NET', 'SQL', 'APIs'], experience: [], education: [], customSections: [], renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' }, generationContextHash: 'def456', lastGeneratedAtUtc: new Date().toISOString(), lastEditedAtUtc: null, status: 'generated', renderedText: 'Senior Backend Engineer\n\nProfessional Summary\n- Owned API delivery\n- Improved SQL workflows', isLegacyFallback: false, } } as any); } if (url === '/jobapplications/42/tailored-cv-preview') { return Promise.resolve({ data: { templateId: body?.templateId ?? 'ats-minimal', suggestedFileName: `${body?.templateId ?? 'ats-minimal'}.pdf`, html: ``, } } as any); } if (url === '/jobapplications/42/export-tailored-cv-pdf') { return Promise.resolve({ data: new Blob(['pdf'], { type: 'application/pdf' }) } as any); } if (url === '/jobapplications/42/generate-application-package') { return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any); } return Promise.resolve({ data: {} } as any); }); mockedApi.put.mockResolvedValue({ data: {} } as any); }); afterEach(() => { jest.clearAllMocks(); }); test('tailored cv tab loads, regenerates, and saves the structured tailored draft', async () => { renderDialog(); fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i })); expect(await screen.findByDisplayValue('Backend Engineer')).toBeInTheDocument(); expect((await screen.findByLabelText('Summary bullets')) as HTMLInputElement).toHaveValue('Built APIs\nShipped backend work'); fireEvent.click(screen.getByRole('button', { name: /generate tailored draft/i })); expect(await screen.findByDisplayValue('Senior Backend Engineer')).toBeInTheDocument(); const headline = screen.getByDisplayValue('Senior Backend Engineer'); fireEvent.change(headline, { target: { value: 'Principal Backend Engineer' } }); fireEvent.click(screen.getByRole('button', { name: /save tailored draft/i })); await waitFor(() => { expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-draft', expect.objectContaining({ headline: 'Principal Backend Engineer', summary: ['Owned API delivery', 'Improved SQL workflows'], selectedSkills: ['.NET', 'SQL', 'APIs'], status: 'edited', })); }); expect(mockedApi.put).not.toHaveBeenCalledWith('/jobapplications/42/tailored-cv', expect.anything()); }); test('application package drafts save separately from the tailored cv draft', async () => { renderDialog(); fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i })); fireEvent.click(await screen.findByRole('button', { name: /generate application package/i })); const coverLetter = await screen.findByDisplayValue('Draft letter'); const applicationAnswer = await screen.findByDisplayValue('Draft answer'); const recruiterMessage = await screen.findByDisplayValue('Recruiter hello'); fireEvent.change(coverLetter, { target: { value: 'Edited cover letter' } }); fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } }); fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } }); fireEvent.click(screen.getByRole('button', { name: /save package drafts/i })); await waitFor(() => { expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', { coverLetterText: 'Edited cover letter', notes: 'Original notes\n\n<<>>\nEdited answer\n<<>>', recruiterMessageDraft: 'Edited recruiter note', }); }); }); test('template switching refreshes preview and export uses the selected template payload', async () => { renderDialog(); fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i })); const comboboxes = await screen.findAllByRole('combobox'); fireEvent.mouseDown(comboboxes[1]); fireEvent.click(await screen.findByRole('option', { name: 'Harvard' })); const accent = screen.getByLabelText('Accent'); fireEvent.change(accent, { target: { value: '#123456' } }); fireEvent.click(screen.getByRole('button', { name: /preview pdf layout/i })); await waitFor(() => { expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-preview', expect.objectContaining({ templateId: 'harvard', renderOptions: expect.objectContaining({ accentColor: '#123456' }), useProfileAvatar: true, })); }); expect(await screen.findByTitle('Tailored CV preview')).toBeInTheDocument(); const appendChildSpy = jest.spyOn(document.body, 'appendChild'); const removeSpy = jest.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => {}); const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); fireEvent.click(screen.getByRole('button', { name: /download pdf/i })); await waitFor(() => { expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/export-tailored-cv-pdf', expect.objectContaining({ templateId: 'harvard', renderOptions: expect.objectContaining({ accentColor: '#123456' }), }), expect.objectContaining({ responseType: 'blob' })); expect(URL.createObjectURL).toHaveBeenCalled(); expect(clickSpy).toHaveBeenCalled(); }); appendChildSpy.mockRestore(); removeSpy.mockRestore(); clickSpy.mockRestore(); }); test('strategy snapshot can be generated from overview', async () => { renderDialog(); expect(await screen.findByRole('button', { name: /generate strategy snapshot/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /generate strategy snapshot/i })); expect(await screen.findByText(/lead with backend delivery and measurable outcomes/i)).toBeInTheDocument(); expect(await screen.findByText(/highlight \.net ownership/i)).toBeInTheDocument(); });