fix(cv): support application variant deep links
The Application Workspace CV section linked to /cv-builder?variant={id}. That
route does not exist: the builder is mounted at /career/builder/:id and reads the
variant from the path, not a query string. The button dead-ended.
Corrected the href. No loading logic was added — the editor already loads the
variant by id and already has a safe error state, and ownership is already
enforced server-side, where CvVariantService scopes every read to the owner and
the controller returns 404.
Added tests for the deep-link entry point, which had none: the variant loads from
the route, a missing variant shows the error state rather than an empty editor,
and another user's variant is refused identically. The asset test now asserts the
exact href, so a route that the router does not serve fails the build instead of
shipping.
118 frontend tests and the production build pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -74,8 +74,10 @@ test("cv section shows the attached variant and the ones available to attach", a
|
|||||||
|
|
||||||
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Theme nordic · version 4/)).toBeInTheDocument();
|
expect(screen.getByText(/Theme nordic · version 4/)).toBeInTheDocument();
|
||||||
|
// Must be the builder's real route (/career/builder/:id). An href the router does not serve
|
||||||
|
// silently dead-ends the user, which is exactly what shipped before this test existed.
|
||||||
expect(screen.getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute(
|
expect(screen.getByRole("link", { name: /Edit, preview and export/i })).toHaveAttribute(
|
||||||
"href", "/cv-builder?variant=3");
|
"href", "/career/builder/3");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("attaching a different variant only re-points the application", async () => {
|
test("attaching a different variant only re-points the application", async () => {
|
||||||
|
|||||||
@@ -148,7 +148,9 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
|
|||||||
size="small"
|
size="small"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
endIcon={<OpenInNewIcon fontSize="small" />}
|
endIcon={<OpenInNewIcon fontSize="small" />}
|
||||||
href={`/cv-builder?variant=${data.attachedVariantId}`}
|
// The builder's real route. It loads the variant from :id and enforces ownership
|
||||||
|
// server-side, so there is no second CV loading path here.
|
||||||
|
href={`/career/builder/${data.attachedVariantId}`}
|
||||||
>
|
>
|
||||||
Edit, preview and export
|
Edit, preview and export
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import CvBuilderEditor from './views/CvBuilderEditor';
|
||||||
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
|
import { ToastProvider } from './toast';
|
||||||
|
import { api } from './api';
|
||||||
|
import { emptyCvVariantSettings } from './cvBuilder';
|
||||||
|
|
||||||
|
// The Application Workspace CV section deep-links straight into the builder at
|
||||||
|
// /career/builder/:id. These tests cover that entry point: the variant loads, and a variant the
|
||||||
|
// server refuses (missing, or someone else's) lands on a safe error state rather than a blank editor.
|
||||||
|
jest.mock('./api', () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
getApiErrorMessage: (_e: any, fallback?: string) => fallback || 'Request failed.',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
const variant = {
|
||||||
|
id: 3,
|
||||||
|
name: 'Backend CV',
|
||||||
|
settings: emptyCvVariantSettings(),
|
||||||
|
isPublic: false,
|
||||||
|
publicSlug: 'abc123',
|
||||||
|
version: 4,
|
||||||
|
jobApplicationId: 7,
|
||||||
|
updatedAtUtc: '2026-07-19T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderAt(id: number) {
|
||||||
|
return render(
|
||||||
|
<I18nProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={[`/career/builder/${id}`]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/career/builder/:id" element={<CvBuilderEditor />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
function routeGet(onVariant: () => Promise<any>) {
|
||||||
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/cv/variants/3') return onVariant();
|
||||||
|
if (url === '/cv/themes') return Promise.resolve({ data: [] } as any);
|
||||||
|
if (url === '/cv/outline') return Promise.resolve({ data: { sections: [] } } as any);
|
||||||
|
return Promise.resolve({ data: [] } as any);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deep link loads the exact variant named in the route', async () => {
|
||||||
|
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||||
|
|
||||||
|
renderAt(3);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('Backend CV')).toBeInTheDocument();
|
||||||
|
expect(mockedApi.get).toHaveBeenCalledWith('/cv/variants/3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a variant that does not exist shows a safe error instead of an empty editor', async () => {
|
||||||
|
routeGet(() => Promise.reject(new Error('404')));
|
||||||
|
|
||||||
|
renderAt(3);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Could not open this CV/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByDisplayValue('Backend CV')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("another user's variant is refused by the server and never rendered", async () => {
|
||||||
|
// Ownership is enforced server-side: CvVariantService scopes every read to the owner and the
|
||||||
|
// controller returns 404, so the client only ever sees the same safe failure.
|
||||||
|
routeGet(() => Promise.reject({ response: { status: 404 } }));
|
||||||
|
|
||||||
|
renderAt(3);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Could not open this CV/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user