acf60c2a07
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while keeping the app's actual routing/rendering model unchanged -- the app is almost entirely behind auth with no proven SSR/SEO need, so a real App Router rewrite would touch ~90 files for zero user-visible benefit. - next.config.js: output:'export' (static HTML+JS, same "single index.html served by nginx with try_files fallback" deploy as CRA). - app/layout.tsx + app/page.tsx: root shell ports public/index.html's <head>, mounts the whole existing App tree client-only (ssr:false) since it reads window/localStorage during initial render and Next's static prerender would otherwise execute that on the server. - Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects any `pages/` dir under the app root and tried to build our React Router page components as its own routes). - REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development, Dockerfile, docker-compose.yml build args. - Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported under Turbopack) with a small inline JobbjaktMark component. - TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax 4.9's parser rejects; CRA never hit this because babel doesn't type-check). - Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts, public/index.html); kept react-scripts as the Jest test runner only (next/jest migration not needed -- the existing config already works). Verified: `next build` static export succeeds, `next dev` serves the landing page and client-side routes (login etc.) correctly, all 57 frontend tests + 172 backend tests still green. Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s in `next dev` since there's no server route for it -- the app only ever mounts at "/". Production is unaffected: nginx's existing try_files fallback still serves index.html for any path.
305 lines
11 KiB
TypeScript
305 lines
11 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 { api } from './api';
|
|
|
|
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
|
|
const revokeObjectURLMock = jest.fn();
|
|
Object.defineProperty(window.URL, 'createObjectURL', {
|
|
writable: true,
|
|
value: createObjectURLMock,
|
|
});
|
|
Object.defineProperty(window.URL, 'revokeObjectURL', {
|
|
writable: true,
|
|
value: revokeObjectURLMock,
|
|
});
|
|
|
|
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>;
|
|
|
|
const REWRITE_TEMPLATES_COUNT = 6;
|
|
|
|
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 renderPage() {
|
|
return render(
|
|
<ToastProvider>
|
|
<I18nProvider>
|
|
<ProfilePage />
|
|
</I18nProvider>
|
|
</ToastProvider>,
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockedApi.get.mockImplementation((url: string) => {
|
|
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: [
|
|
{
|
|
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',
|
|
},
|
|
],
|
|
} 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/rewrite-preview') {
|
|
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
|
|
}
|
|
if (url === '/profile-cv/export-pdf') {
|
|
return Promise.resolve({ data: new Blob([`pdf-${payload?.templateId ?? 'ats-minimal'}`], { type: 'application/pdf' }), config } 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();
|
|
createObjectURLMock.mockClear();
|
|
revokeObjectURLMock.mockClear();
|
|
});
|
|
|
|
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(/cv structure overview/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/structured cv editor/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/extraction history/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/resume.pdf/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/current run/i)).toBeInTheDocument();
|
|
expect(screen.getAllByText(/original extraction/i).length).toBeGreaterThan(0);
|
|
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/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');
|
|
|
|
const analyzeButton = screen.getByRole('button', { name: /analyze 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('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(/the structured cv stays front and center/i)).toBeInTheDocument();
|
|
|
|
const originalExtractionToggle = screen.getByRole('button', { name: /original extraction/i });
|
|
expect(originalExtractionToggle).toHaveAttribute('aria-expanded', 'false');
|
|
const copyButton = screen.getByRole('button', { name: /copy cv text/i });
|
|
expect(copyButton).toBeDisabled();
|
|
|
|
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');
|
|
const copyButtons = screen.getAllByRole('button', { name: /copy cv text/i });
|
|
expect(copyButtons.some((button) => !button.hasAttribute('disabled'))).toBe(true);
|
|
});
|
|
|
|
test('profile page rewrite tools use selected template and saved job context', async () => {
|
|
renderPage();
|
|
|
|
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByText(/harvard/i));
|
|
fireEvent.change(screen.getByLabelText(/prompt-based cv brief/i), { target: { value: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.' } });
|
|
fireEvent.change(screen.getByLabelText(/target role/i), { target: { value: 'Senior Platform Engineer' } });
|
|
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
|
|
fireEvent.click(rewriteButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-preview', expect.objectContaining({
|
|
sectionName: null,
|
|
style: 'harvard',
|
|
templateId: 'harvard',
|
|
jobApplicationId: null,
|
|
promptBackground: 'Highlight backend platform ownership, distributed systems, and cross-team delivery.',
|
|
targetRole: 'Senior Platform Engineer',
|
|
language: 'English',
|
|
tone: 'Concise and direct',
|
|
}));
|
|
});
|
|
|
|
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('heading', { name: /pdf carousel/i })).toBeInTheDocument();
|
|
|
|
const buildCarouselButton = screen.getByRole('button', { name: /build pdf carousel/i });
|
|
fireEvent.click(buildCarouselButton);
|
|
|
|
await waitFor(() => {
|
|
const exportCalls = mockedApi.post.mock.calls.filter(([url]) => url === '/profile-cv/export-pdf');
|
|
expect(exportCalls.length).toBe(REWRITE_TEMPLATES_COUNT);
|
|
});
|
|
|
|
await waitFor(() => expect(createObjectURLMock).toHaveBeenCalledTimes(REWRITE_TEMPLATES_COUNT));
|
|
});
|
|
|
|
test('saving profile persists structured cv json', async () => {
|
|
renderPage();
|
|
|
|
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).toHaveBeenCalled();
|
|
});
|
|
|
|
const payload = mockedApi.put.mock.calls[0][1] as any;
|
|
const parsed = JSON.parse(payload.profileCvStructureJson);
|
|
expect(parsed.contact.fullName).toBe('Updated Demo User');
|
|
expect(parsed.skills).toEqual(['.NET', 'SQL']);
|
|
expect(parsed.jobs[0].title).toBe('System Developer');
|
|
});
|