4759f1f610
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>
190 lines
7.1 KiB
TypeScript
190 lines
7.1 KiB
TypeScript
import React from "react";
|
|
import "@testing-library/jest-dom";
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
|
|
import {
|
|
ApplicationCoverLetterSection, ApplicationCvSection,
|
|
} from "./components/ApplicationAssets";
|
|
import { api } from "./api";
|
|
|
|
jest.mock("./api", () => ({
|
|
api: {
|
|
get: jest.fn(),
|
|
put: jest.fn(),
|
|
post: 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 cv = {
|
|
attachedVariantId: 3,
|
|
attachedVariantName: "Backend CV",
|
|
attachedThemeId: "nordic",
|
|
attachedVersion: 4,
|
|
attachedUpdatedAtUtc: "2026-07-19T10:00:00Z",
|
|
attachedIsPublic: false,
|
|
hasTailoredCvText: false,
|
|
availableVariants: [
|
|
{ id: 3, name: "Backend CV", themeId: "nordic", publicSlug: "abc", isPublic: false, version: 4, jobApplicationId: 7, updatedAtUtc: "2026-07-19T10:00:00Z" },
|
|
{ id: 5, name: "Generalist CV", themeId: "modern", publicSlug: "def", isPublic: false, version: 2, jobApplicationId: null, updatedAtUtc: "2026-07-18T10:00:00Z" },
|
|
],
|
|
};
|
|
|
|
const tailoring = {
|
|
hasJobDescription: true,
|
|
hasCareerProfile: true,
|
|
hasAttachedVariant: true,
|
|
matchScore: 72,
|
|
suggestions: [
|
|
{ kind: "highlight-skills", title: "Skills to highlight", detail: "The advert asks for these.", items: ["C#", "SQL"] },
|
|
{ kind: "gaps", title: "Gaps to address", detail: null, items: ["Kubernetes"] },
|
|
],
|
|
aiSuggestionCount: 0,
|
|
};
|
|
|
|
const coverLetter = {
|
|
text: "Dear team",
|
|
currentVersion: 2,
|
|
versions: [
|
|
{ version: 2, source: "ai", aiAction: "improve", length: 9, createdAtUtc: "2026-07-19T10:00:00Z", isCurrent: true },
|
|
{ version: 1, source: "manual", aiAction: null, length: 40, createdAtUtc: "2026-07-19T09:00:00Z", isCurrent: false },
|
|
],
|
|
aiSuggestionCount: 1,
|
|
};
|
|
|
|
function routeGet(overrides: Record<string, any> = {}) {
|
|
mockedApi.get.mockImplementation((url: string) => {
|
|
if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any);
|
|
if (url.endsWith("/cover-letter")) return Promise.resolve({ data: overrides.coverLetter ?? coverLetter } as any);
|
|
return Promise.resolve({ data: overrides.cv ?? cv } as any);
|
|
});
|
|
}
|
|
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
// ---------- CV ----------
|
|
|
|
test("cv section shows the attached variant and the ones available to attach", async () => {
|
|
routeGet();
|
|
|
|
render(<ApplicationCvSection jobId={7} />);
|
|
|
|
expect(await screen.findByText("Backend CV")).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(
|
|
"href", "/career/builder/3");
|
|
});
|
|
|
|
test("attaching a different variant only re-points the application", async () => {
|
|
routeGet();
|
|
mockedApi.put.mockResolvedValue({ data: { ...cv, attachedVariantId: 5, attachedVariantName: "Generalist CV" } } as any);
|
|
|
|
render(<ApplicationCvSection jobId={7} />);
|
|
fireEvent.mouseDown(await screen.findByRole("combobox", { name: /Attached CV variant/i }));
|
|
fireEvent.click(await screen.findByRole("option", { name: /Generalist CV/ }));
|
|
|
|
await waitFor(() =>
|
|
expect(mockedApi.put).toHaveBeenCalledWith("/jobapplications/7/cv", { variantId: 5 }));
|
|
});
|
|
|
|
test("cv section points at the builder when there are no variants", async () => {
|
|
routeGet({ cv: { ...cv, attachedVariantId: null, attachedVariantName: null, availableVariants: [] } });
|
|
|
|
render(<ApplicationCvSection jobId={7} />);
|
|
|
|
expect(await screen.findByText(/No CV variants yet/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("tailoring renders suggestions grouped by kind", async () => {
|
|
routeGet();
|
|
|
|
render(<ApplicationCvSection jobId={7} />);
|
|
|
|
expect(await screen.findByText("Skills to highlight")).toBeInTheDocument();
|
|
expect(screen.getByText("Gaps to address")).toBeInTheDocument();
|
|
expect(screen.getByText("Kubernetes")).toBeInTheDocument();
|
|
});
|
|
|
|
test("tailoring asks for a career profile when there is none", async () => {
|
|
routeGet({ tailoring: { ...tailoring, hasCareerProfile: false, suggestions: [] } });
|
|
|
|
render(<ApplicationCvSection jobId={7} />);
|
|
|
|
expect(await screen.findByText(/Build your career profile/i)).toBeInTheDocument();
|
|
});
|
|
|
|
// ---------- Cover letter ----------
|
|
|
|
test("cover letter loads the current text and its history", async () => {
|
|
routeGet();
|
|
|
|
render(<ApplicationCoverLetterSection jobId={7} />);
|
|
|
|
expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument();
|
|
expect(screen.getByText("v2")).toBeInTheDocument();
|
|
expect(screen.getByText("ai · improve")).toBeInTheDocument();
|
|
expect(screen.getByText("Current")).toBeInTheDocument();
|
|
});
|
|
|
|
test("editing marks the draft dirty and saving sends the new text", async () => {
|
|
routeGet();
|
|
mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "Dear hiring team" } } as any);
|
|
|
|
render(<ApplicationCoverLetterSection jobId={7} />);
|
|
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "Dear hiring team" } });
|
|
|
|
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
|
|
|
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
|
|
"/jobapplications/7/cover-letter",
|
|
{ text: "Dear hiring team", source: "manual", aiAction: undefined },
|
|
));
|
|
});
|
|
|
|
test("discarding returns to the saved text", async () => {
|
|
routeGet();
|
|
|
|
render(<ApplicationCoverLetterSection jobId={7} />);
|
|
fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "scratch" } });
|
|
fireEvent.click(screen.getByRole("button", { name: /Discard changes/i }));
|
|
|
|
expect(await screen.findByDisplayValue("Dear team")).toBeInTheDocument();
|
|
expect(mockedApi.put).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("restoring an old version calls the restore endpoint", async () => {
|
|
routeGet();
|
|
mockedApi.post.mockResolvedValue({ data: coverLetter } as any);
|
|
|
|
render(<ApplicationCoverLetterSection jobId={7} />);
|
|
fireEvent.click(await screen.findByRole("button", { name: /Restore version 1/i }));
|
|
|
|
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
|
|
"/jobapplications/7/cover-letter/versions/1/restore"));
|
|
});
|
|
|
|
test("an empty cover letter offers the template and an empty history", async () => {
|
|
routeGet({ coverLetter: { text: null, currentVersion: 0, versions: [], aiSuggestionCount: 0 } });
|
|
|
|
render(<ApplicationCoverLetterSection jobId={7} />);
|
|
|
|
expect(await screen.findByText(/No versions yet/i)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole("button", { name: /Start from template/i }));
|
|
expect((screen.getByLabelText("Cover letter") as HTMLTextAreaElement).value)
|
|
.toContain("Dear Hiring Manager");
|
|
});
|
|
|
|
test("a failed load surfaces an error", async () => {
|
|
mockedApi.get.mockRejectedValue(new Error("boom"));
|
|
|
|
render(<ApplicationCoverLetterSection jobId={7} />);
|
|
|
|
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
|
|
});
|