import React from "react"; import "@testing-library/jest-dom"; import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react"; import { ApplicationCoverLetterSection, ApplicationCvSection, ApplicationPackageDraftsSection, } from "./components/ApplicationAssets"; import { api } from "./api"; import { I18nProvider } from "./i18n/I18nProvider"; const render = (ui: React.ReactElement) => rtlRender({ui}); 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; 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 = {}) { 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(() => { window.localStorage.removeItem("uiLanguage"); jest.clearAllMocks(); }); afterEach(() => window.localStorage.removeItem("uiLanguage")); // ---------- CV ---------- test("cv section shows the attached variant and the ones available to attach", async () => { routeGet(); render(); 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(); 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(); expect(await screen.findByText(/No CV variants yet/i)).toBeInTheDocument(); }); test("tailoring renders suggestions grouped by kind", async () => { routeGet(); render(); 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(); 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(); 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); const onDirtyChange = jest.fn(); render(); fireEvent.change(await screen.findByLabelText("Cover letter"), { target: { value: "Dear hiring team" } }); expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); expect(onDirtyChange).toHaveBeenLastCalledWith(true); 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(); 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(); 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(); 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("the empty cover-letter template and rich-text controls follow Bokmål UI language", async () => { window.localStorage.setItem("uiLanguage", "nb"); routeGet({ coverLetter: { text: null, currentVersion: 0, versions: [], aiSuggestionCount: 0 } }); render(); fireEvent.click(await screen.findByRole("button", { name: /start fra mal/i })); expect((screen.getByLabelText("Søknadsbrev") as HTMLTextAreaElement).value) .toContain("Kjære rekrutteringsansvarlig"); expect(screen.getByRole("button", { name: "Fet" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Lenke" })).toBeInTheDocument(); }); test("AI cover letter suggestions use the linked CV and require explicit apply", async () => { routeGet(); mockedApi.post.mockResolvedValue({ data: { id: 9, module: "cover-letter", mode: "professional", title: "Cover letter · Generate · Professional", provider: "local", result: { text: "A tailored, truthful suggestion." }, createdAtUtc: "2026-07-19T11:00:00Z", }, } as any); render(); expect(await screen.findByText(/application's full job advert and analysis/i)).toBeInTheDocument(); const assistantSelects = screen.getAllByRole("combobox"); fireEvent.mouseDown(assistantSelects[assistantSelects.length - 1]); fireEvent.click(await screen.findByRole("option", { name: /Norsk bokmål/i })); fireEvent.click(screen.getByRole("button", { name: "Generate" })); await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith( "/jobapplications/7/ai/generate", expect.objectContaining({ module: "cover-letter", targetLanguage: "nb-NO", currentText: "Dear team", action: "generate", }), )); expect(screen.getByLabelText("Cover letter")).toHaveValue("Dear team"); fireEvent.click(await screen.findByRole("button", { name: /Apply to editor/i })); expect(screen.getByLabelText("Cover letter")).toHaveValue("A tailored, truthful suggestion."); mockedApi.put.mockResolvedValue({ data: { ...coverLetter, text: "A tailored, truthful suggestion." } } as any); fireEvent.click(screen.getByRole("button", { name: "Save" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( "/jobapplications/7/cover-letter", { text: "A tailored, truthful suggestion.", source: "ai", aiAction: "generate" }, )); }); test("a failed load surfaces an error", async () => { mockedApi.get.mockRejectedValue(new Error("boom")); render(); expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument(); }); // ---------- Application package drafts ---------- test("application answer and recruiter drafts save from the dedicated workspace", async () => { mockedApi.put.mockResolvedValue({ data: undefined } as any); const onSaved = jest.fn(); render( , ); fireEvent.change(screen.getByLabelText("Application answer"), { target: { value: "Edited answer" } }); fireEvent.change(screen.getByLabelText("Recruiter message"), { target: { value: "Edited recruiter note" } }); expect(screen.getByText("Unsaved changes")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Save application drafts" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( "/jobapplications/7/application-drafts", { applicationAnswerDraft: "Edited answer", recruiterMessageDraft: "Edited recruiter note", }, )); expect(onSaved).toHaveBeenCalled(); }); test("application package drafts can be cleared without deleting ordinary notes", async () => { mockedApi.put.mockResolvedValue({ data: undefined } as any); render( , ); fireEvent.change(screen.getByLabelText("Application answer"), { target: { value: "" } }); fireEvent.change(screen.getByLabelText("Recruiter message"), { target: { value: "" } }); fireEvent.click(screen.getByRole("button", { name: "Save application drafts" })); await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith( "/jobapplications/7/application-drafts", { applicationAnswerDraft: "", recruiterMessageDraft: "" }, )); });