Files
jobtrackingapp/job-tracker-ui/src/profile-page.test.tsx
T
cesnimda 56fed05d70
CI and Deploy / test (push) Successful in 2m42s
CI and Deploy / deploy (push) Failing after 9s
feat: complete phase 2 UX improvements
2026-07-30 21:35:52 +02:00

361 lines
14 KiB
TypeScript

import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import ProfilePage from './views/ProfilePage';
import CareerProfilePage from './views/CareerProfilePage';
import { api } from './api';
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() } },
},
}));
jest.mock('./components/GoogleAuthCard', () => () => null);
jest.mock('./components/CropImageDialog', () => () => null);
const mockedApi = api as jest.Mocked<typeof api>;
let extractionRunsResponse: any[] = [];
const structuredCv = {
version: '1',
metadata: {
profileVersion: 3,
appliedExtractionRunId: 12,
updatedAtUtc: '2026-03-28T12:00:00Z',
fields: {
'contact.fullName': { confidence: 0.92, method: 'llm', sourceBlockId: 'block-1', reviewState: 'suggested', sourceSnippet: 'Demo User' },
summary: { confidence: 0.71, method: 'deterministic', sourceBlockId: 'block-2', reviewState: 'suggested', sourceSnippet: 'Built backend systems' },
skills: { confidence: 0.68, method: 'deterministic', sourceBlockId: 'block-3', reviewState: 'suggested', sourceSnippet: '.NET' },
},
},
contact: {
fullName: 'Demo User',
headline: 'Backend Developer',
email: 'demo@example.com',
},
summary: ['Built backend systems'],
jobs: [
{
title: 'System Developer',
company: 'Demo Co',
location: 'Oslo',
start: '2020',
end: '2024',
isCurrent: false,
bullets: ['Built backend systems'],
skills: ['.NET', 'SQL'],
},
],
education: [],
skills: ['.NET', 'SQL'],
languages: [{ name: 'English', level: 'Native' }],
interests: ['Cooking'],
otherSections: [],
sections: [
{ name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 },
{ name: 'Skills', content: '.NET\nSQL', wordCount: 2 },
],
};
function renderWith(Component: React.ComponentType) {
return render(
<ToastProvider>
<I18nProvider>
<Component />
</I18nProvider>
</ToastProvider>,
);
}
// These exercise the master-CV editing surface, which lives on /career (CareerProfilePage) after
// the Phase 2.2 split. ProfilePage no longer carries it.
function renderPage() {
return renderWith(CareerProfilePage);
}
void ProfilePage;
beforeEach(() => {
extractionRunsResponse = [{
id: 12,
trigger: 'upload',
status: 'applied',
artifactFileName: 'resume.pdf',
startedAtUtc: '2026-03-28T12:00:00Z',
completedAtUtc: '2026-03-28T12:00:05Z',
appliedAtUtc: '2026-03-28T12:00:05Z',
parserVersion: 'm005-s01',
normalizerVersion: 'm005-s01',
llmPromptVersion: 'm005-s01',
}];
mockedApi.get.mockImplementation((url: string) => {
if (url === '/career/profile') {
// Phase 3: /career reads the structured profile from the relational source of truth.
return Promise.resolve({
data: {
profile: structuredCv,
cvText: 'Professional Summary\nBuilt backend systems',
completeness: { percent: 70, missing: ['Projects'], sections: [] },
},
} as any);
}
if (url === '/career/profile/versions') {
return Promise.resolve({ data: [] } as any);
}
if (url === '/auth/me') {
return Promise.resolve({
data: {
provider: 'local',
email: 'demo@example.com',
userName: 'demo',
firstName: 'Demo',
lastName: 'User',
displayName: 'Demo User',
profileCvText: 'Professional Summary\nBuilt backend systems',
profileCvStructureJson: JSON.stringify(structuredCv),
googleLink: { linked: false },
},
} as any);
}
if (url === '/profile-cv/runs') {
return Promise.resolve({ data: extractionRunsResponse } as any);
}
if (/^\/profile-cv\/runs\/\d+\/diff$/.test(url)) {
return Promise.resolve({ data: {
runId: 13,
status: 'pending_review',
diff: { totalAdded: 3, totalUpdated: 1, totalLowConfidence: 1, hasChanges: true, categories: [
{ category: 'Skills', added: [{ id: 'Skills|docker', label: 'Docker', confidence: 'High' }], updated: [], unchangedCount: 2, lowConfidenceCount: 0 },
{ category: 'Languages', added: [{ id: 'Languages|french', label: 'French', confidence: 'Low' }], updated: [], unchangedCount: 1, lowConfidenceCount: 1 },
{ category: 'Experience', added: [{ id: 'Experience|new co engineer', label: 'Engineer - New Co', confidence: 'High' }], updated: [{ id: 'Experience|demo co developer', label: 'Developer - Demo Co', confidence: 'High' }], unchangedCount: 0, lowConfidenceCount: 0 },
] },
} } as any);
}
if (url === '/jobapplications') {
return Promise.resolve({
data: {
items: [
{
id: 42,
jobTitle: 'Senior Backend Engineer',
company: { id: 7, name: 'Acme Systems' },
status: 'Waiting',
dateApplied: '2026-03-20',
daysSince: 10,
description: 'Build API integrations and platform workflows.',
responseReceived: false,
},
],
total: 1,
page: 1,
pageSize: 100,
},
} as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.post.mockImplementation((url: string, payload?: any, config?: any) => {
if (url === '/profile-cv/parse') {
return Promise.resolve({
data: {
structuredCv: {
...structuredCv,
sections: [
{ name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 },
{ name: 'Core Skills', content: '.NET\nSQL\nAzure', wordCount: 3 },
],
skills: ['.NET', 'SQL', 'Azure'],
},
},
} as any);
}
if (url === '/profile-cv/reprocess') {
return Promise.resolve({ data: { reprocessed: true } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
window.localStorage.clear();
});
afterEach(() => {
jest.clearAllMocks();
});
test('profile page loads persisted structured cv and can re-parse it', async () => {
renderPage();
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
expect(screen.getByText(/profile sections/i)).toBeInTheDocument();
expect(screen.getAllByText(/career information/i).length).toBeGreaterThan(0);
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
expect(screen.getByText(/current run/i)).toBeInTheDocument();
expect(screen.queryByText(/template-driven cv builder/i)).not.toBeInTheDocument();
expect(screen.getByRole('link', { name: /open cv builder/i })).toHaveAttribute('href', '/career/builder');
expect(screen.getAllByText(/original import/i).length).toBeGreaterThan(0);
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0);
expect(screen.getByLabelText(/full name/i)).toHaveValue('Demo User');
expect(screen.getByText(/high 92%/i)).toBeInTheDocument();
expect(screen.getByText(/block-1/i)).toBeInTheDocument();
fireEvent.click(originalExtractionToggle);
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
// Structure overview is hidden from the default workflow (Phase 1 increment 2); reveal it.
fireEvent.click(screen.getByRole('button', { name: /advanced cv tools/i }));
const analyzeButton = screen.getByRole('button', { name: /read sections/i });
await waitFor(() => expect(analyzeButton).toBeEnabled());
fireEvent.click(analyzeButton);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/parse', { text: 'Professional Summary\nBuilt backend systems' });
});
expect(screen.getAllByText(/core skills/i).length).toBeGreaterThan(0);
});
test('saving the career profile PUTs the structured profile and cv text unchanged (Phase 1 refactor invariant)', async () => {
renderPage();
const saveButton = await screen.findByRole('button', { name: /save changes/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith(
'/career/profile',
expect.objectContaining({
profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Demo User' }) }),
cvText: 'Professional Summary\nBuilt backend systems',
}),
);
});
});
test('editing a field in an extracted section updates parent state and flows into save (Phase 1 increment 2)', async () => {
renderPage();
// Renders existing data through the extracted PersonalInformationSection.
const nameField = await screen.findByLabelText(/full name/i);
expect(nameField).toHaveValue('Demo User');
// Editing the child field updates the parent's structuredCv (controlled input reflects it back).
fireEvent.change(nameField, { target: { value: 'Edited Name' } });
expect(nameField).toHaveValue('Edited Name');
const saveButton = screen.getByRole('button', { name: /save changes/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
// The edited value reaches the unchanged save path.
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith(
'/career/profile',
expect.objectContaining({
profile: expect.objectContaining({ contact: expect.objectContaining({ fullName: 'Edited Name' }) }),
cvText: expect.any(String),
}),
);
});
});
test('profile page can reprocess from stored artifact history', async () => {
renderPage();
expect(await screen.findByText(/extraction history/i)).toBeInTheDocument();
const reprocessButton = screen.getByRole('button', { name: /reprocess cv/i });
fireEvent.click(reprocessButton);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/reprocess');
});
});
test('profile page keeps raw extraction collapsed until expanded', async () => {
renderPage();
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
expect(screen.getByText(/your career information stays front and center/i)).toBeInTheDocument();
const originalExtractionToggle = screen.getByRole('button', { name: /original import/i });
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByRole('button', { name: /copy cv text/i })).not.toBeInTheDocument();
fireEvent.click(originalExtractionToggle);
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'true');
expect(await screen.findByLabelText(/profile cv \/ master resume text/i)).toHaveValue('Professional Summary\nBuilt backend systems');
expect(screen.getByRole('button', { name: /copy cv text/i })).toBeEnabled();
});
test('saving the master profile (career) persists the structured profile via /career/profile', async () => {
// Phase 3: /career saves the master profile through the relational API. The payload carries the
// structured profile object (not the legacy flat blob) and never identity fields.
mockedApi.put.mockResolvedValue({ data: { profile: structuredCv, completeness: { percent: 70, missing: [], sections: [] }, cvText: '' } } as any);
renderWith(CareerProfilePage);
expect(await screen.findByText(/cv ready/i)).toBeInTheDocument();
const fullNameInput = screen.getByLabelText(/full name/i);
fireEvent.change(fullNameInput, { target: { value: 'Updated Demo User' } });
const saveButton = screen.getByRole('button', { name: /save changes/i });
await waitFor(() => expect(saveButton).toBeEnabled());
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/career/profile', expect.anything());
});
const payload = mockedApi.put.mock.calls[0][1] as any;
expect(payload.profile.contact.fullName).toBe('Updated Demo User');
expect(payload.profile.skills).toEqual(['.NET', 'SQL']);
expect(payload.profile.jobs[0].title).toBe('System Developer');
// The career save must NOT carry identity fields (they belong to /profile).
expect(payload.email).toBeUndefined();
expect(payload.displayName).toBeUndefined();
});
test('/career shows the profile completeness overview', async () => {
renderWith(CareerProfilePage);
expect(await screen.findByText(/profile completeness/i)).toBeInTheDocument();
expect(screen.getByText(/70%/)).toBeInTheDocument();
});
test('pending CV extraction can be reviewed and applied', async () => {
extractionRunsResponse = [{
id: 13,
trigger: 'upload',
status: 'pending_review',
artifactFileName: 'new-resume.pdf',
startedAtUtc: '2026-03-29T12:00:00Z',
completedAtUtc: '2026-03-29T12:00:05Z',
parserVersion: 'm005-s01',
normalizerVersion: 'm005-s01',
llmPromptVersion: 'm005-s01',
}];
renderPage();
expect(await screen.findByText(/3 additions/i)).toBeInTheDocument();
const lowConfidence = screen.getByRole('checkbox', { name: /include low-confidence languages: french/i });
expect(lowConfidence).not.toBeChecked();
fireEvent.click(lowConfidence);
fireEvent.click(screen.getByRole('button', { name: /apply changes/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/runs/13/accept', {
acceptedLowConfidenceIds: ['Languages|french'],
}));
});