207 lines
7.1 KiB
TypeScript
207 lines
7.1 KiB
TypeScript
import React from "react";
|
||
import "@testing-library/jest-dom";
|
||
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||
|
||
import {
|
||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||
} from "./components/ApplicationIntelligence";
|
||
import { api } from "./api";
|
||
import { I18nProvider } from "./i18n/I18nProvider";
|
||
|
||
const render = (ui: React.ReactElement) => rtlRender(<I18nProvider>{ui}</I18nProvider>);
|
||
|
||
jest.mock("./api", () => ({
|
||
api: {
|
||
get: 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 timeline = {
|
||
days: [
|
||
{
|
||
date: "2026-07-19",
|
||
label: "Today",
|
||
events: [
|
||
{ id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" },
|
||
{ id: 3, type: "AiRefreshed", category: "ai", summary: "AI suggestions refreshed", detail: null, isMilestone: false, at: "2026-07-19T08:00:00Z" },
|
||
],
|
||
},
|
||
],
|
||
milestones: [
|
||
{ id: 2, type: "StatusChanged", category: "stage", summary: "Moved from Applied to Interview", detail: null, isMilestone: true, at: "2026-07-19T09:00:00Z" },
|
||
],
|
||
categories: ["ai", "stage"],
|
||
totalEvents: 2,
|
||
};
|
||
|
||
const analysis = {
|
||
role: "Senior Backend Developer",
|
||
company: "Acme",
|
||
location: "Oslo",
|
||
employmentType: "Full-time",
|
||
seniority: "Senior",
|
||
salary: null,
|
||
technologies: ["C#", ".NET"],
|
||
skills: ["Collaboration"],
|
||
responsibilities: ["Build and operate REST APIs"],
|
||
keywords: ["C#", ".NET", "Collaboration"],
|
||
summary: "Senior Backend Developer at Acme · Oslo.",
|
||
importantRequirements: ["Strong experience with C# and .NET"],
|
||
interviewTopics: ["C#", ".NET"],
|
||
missingInformation: ["Salary or compensation range"],
|
||
hasJobDescription: true,
|
||
aiSuggestionCount: 0,
|
||
};
|
||
|
||
const match = {
|
||
score: 72,
|
||
band: "Good",
|
||
hasEnoughSignal: true,
|
||
hasCareerProfile: true,
|
||
hasSelectedCv: true,
|
||
selectedCvName: "Backend CV",
|
||
jobLanguage: "en",
|
||
cvLanguage: "en",
|
||
languageMismatch: false,
|
||
usedTranslatedJobDescription: false,
|
||
matchedSkills: ["C#", "SQL"],
|
||
missingSkills: ["Kubernetes"],
|
||
relevantExperience: [{ title: "Backend Developer", subtitle: "Initech · 2021 – present", matched: ["C#"] }],
|
||
relevantProjects: [],
|
||
suggestions: ["Solid match. Lead with the matched skills."],
|
||
aiSuggestionCount: 0,
|
||
};
|
||
|
||
beforeEach(() => jest.clearAllMocks());
|
||
|
||
// ---------- Timeline ----------
|
||
|
||
test("timeline renders grouped days, milestones and readable summaries", async () => {
|
||
mockedApi.get.mockResolvedValue({ data: timeline } as any);
|
||
|
||
render(<ApplicationTimeline jobId={7} />);
|
||
|
||
expect(await screen.findByText("Milestones")).toBeInTheDocument();
|
||
expect(screen.getByText(new Date(`${timeline.days[0].date}T12:00:00`).toLocaleDateString("en", { dateStyle: "long" }))).toBeInTheDocument();
|
||
expect(screen.getByRole("list")).toHaveTextContent("AI suggestions refreshed");
|
||
expect(screen.getAllByText("Moved from Applied to Interview").length).toBeGreaterThan(0);
|
||
expect(screen.getByText("AI suggestions refreshed")).toBeInTheDocument();
|
||
});
|
||
|
||
test("timeline filters by category", async () => {
|
||
mockedApi.get.mockResolvedValue({ data: timeline } as any);
|
||
|
||
render(<ApplicationTimeline jobId={7} />);
|
||
fireEvent.click(await screen.findByRole("button", { name: "AI" }));
|
||
|
||
await waitFor(() =>
|
||
expect(mockedApi.get).toHaveBeenLastCalledWith(
|
||
"/jobapplications/7/timeline",
|
||
{ params: { category: "ai", milestonesOnly: undefined } },
|
||
));
|
||
});
|
||
|
||
test("timeline shows an empty state when nothing has happened", async () => {
|
||
mockedApi.get.mockResolvedValue({ data: { days: [], milestones: [], categories: [], totalEvents: 0 } } as any);
|
||
|
||
render(<ApplicationTimeline jobId={7} />);
|
||
|
||
expect(await screen.findByText(/Nothing has happened yet/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test("timeline surfaces an error instead of rendering nothing", async () => {
|
||
mockedApi.get.mockRejectedValue(new Error("boom"));
|
||
|
||
render(<ApplicationTimeline jobId={7} />);
|
||
|
||
expect(await screen.findByText(/Could not load this section/i)).toBeInTheDocument();
|
||
});
|
||
|
||
// ---------- Analysis ----------
|
||
|
||
test("analysis renders the extracted structure", async () => {
|
||
mockedApi.get.mockResolvedValue({ data: analysis } as any);
|
||
|
||
render(<ApplicationAnalysis jobId={7} />);
|
||
|
||
expect(await screen.findByText("Senior Backend Developer at Acme · Oslo.")).toBeInTheDocument();
|
||
expect(screen.getByText("Full-time")).toBeInTheDocument();
|
||
expect(screen.getByText("Strong experience with C# and .NET")).toBeInTheDocument();
|
||
expect(screen.getByText("Salary or compensation range")).toBeInTheDocument();
|
||
});
|
||
|
||
test("analysis prompts for the advert when there is none", async () => {
|
||
mockedApi.get.mockResolvedValue({
|
||
data: { ...analysis, hasJobDescription: false, technologies: [], responsibilities: [], importantRequirements: [] },
|
||
} as any);
|
||
|
||
render(<ApplicationAnalysis jobId={7} />);
|
||
|
||
expect(await screen.findByText(/No advert text saved yet/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test("analysis shows a loading state before the data arrives", () => {
|
||
mockedApi.get.mockReturnValue(new Promise(() => {}) as any);
|
||
|
||
render(<ApplicationAnalysis jobId={7} />);
|
||
|
||
expect(screen.getByRole("progressbar", { name: "Loading..." })).toBeInTheDocument();
|
||
});
|
||
|
||
// ---------- Match ----------
|
||
|
||
test("match renders the score, evidence and suggestions", async () => {
|
||
mockedApi.get.mockResolvedValue({ data: match } as any);
|
||
|
||
render(<ApplicationMatch jobId={7} />);
|
||
|
||
expect(await screen.findByText("72%")).toBeInTheDocument();
|
||
expect(screen.getByText("Good")).toBeInTheDocument();
|
||
expect(screen.getByText("Backend Developer")).toBeInTheDocument();
|
||
expect(screen.getByText("Kubernetes")).toBeInTheDocument();
|
||
expect(screen.getByText(/Solid match/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test("match asks for a career profile before showing a score", async () => {
|
||
mockedApi.get.mockResolvedValue({
|
||
data: {
|
||
...match, score: 0, band: "No profile", hasCareerProfile: false,
|
||
matchedSkills: [], missingSkills: [], relevantExperience: [],
|
||
suggestions: ["Build your career profile first."],
|
||
},
|
||
} as any);
|
||
|
||
render(<ApplicationMatch jobId={7} />);
|
||
|
||
expect(await screen.findByText(/linked CV cannot be matched/i)).toBeInTheDocument();
|
||
expect(screen.queryByText("0%")).not.toBeInTheDocument();
|
||
});
|
||
|
||
test("match warns when the advert is too short to score", async () => {
|
||
mockedApi.get.mockResolvedValue({ data: { ...match, hasEnoughSignal: false } } as any);
|
||
|
||
render(<ApplicationMatch jobId={7} />);
|
||
|
||
expect(await screen.findByText(/too short to score reliably/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test("match explains when a saved translation is used for a cross-language comparison", async () => {
|
||
mockedApi.get.mockResolvedValue({
|
||
data: {
|
||
...match,
|
||
jobLanguage: "nb",
|
||
cvLanguage: "en",
|
||
languageMismatch: true,
|
||
usedTranslatedJobDescription: true,
|
||
},
|
||
} as any);
|
||
|
||
render(<ApplicationMatch jobId={7} />);
|
||
|
||
expect(await screen.findByText(/uses the saved English translation/i)).toBeInTheDocument();
|
||
});
|