Add CV template preview and PDF export pipeline
This commit is contained in:
@@ -43,6 +43,10 @@ beforeEach(() => {
|
||||
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') {
|
||||
@@ -61,7 +65,7 @@ beforeEach(() => {
|
||||
} } as any);
|
||||
}
|
||||
if (url === '/auth/me') {
|
||||
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
|
||||
return Promise.resolve({ data: { roles: [], avatarImageDataUrl: 'data:image/png;base64,avatar123' } } as any);
|
||||
}
|
||||
if (url === '/jobapplications/42/history') {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
@@ -69,6 +73,26 @@ beforeEach(() => {
|
||||
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);
|
||||
}
|
||||
@@ -77,7 +101,44 @@ beforeEach(() => {
|
||||
}
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
mockedApi.post.mockResolvedValue({ data: { tailoredCvText: 'Generated CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } 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: `<html><body data-template="${body?.templateId ?? 'ats-minimal'}" data-accent="${body?.renderOptions?.accentColor ?? ''}" data-photo="${body?.useProfileAvatar ? 'profile' : 'custom'}"></body></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);
|
||||
});
|
||||
|
||||
@@ -85,20 +146,40 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('application package workspace reflects saved job material, generated drafts, and save state', async () => {
|
||||
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('Saved CV')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
|
||||
expect(await screen.findByText(/saved working material/i)).toBeInTheDocument();
|
||||
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 application package/i }));
|
||||
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 }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Generated CV')).toBeInTheDocument();
|
||||
const coverLetter = await screen.findByDisplayValue('Draft letter');
|
||||
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
|
||||
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
|
||||
@@ -107,18 +188,59 @@ test('application package workspace reflects saved job material, generated draft
|
||||
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
|
||||
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /save package to job/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /save package drafts/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv', { tailoredCvText: 'Generated CV' });
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
|
||||
coverLetterText: 'Edited cover letter',
|
||||
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
|
||||
recruiterMessageDraft: 'Edited recruiter note',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
expect(await screen.findAllByText(/saved to job/i)).not.toHaveLength(0);
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user