Files
jobtrackingapp/job-tracker-ui/src/interview-prep.test.tsx
T
2026-08-28 12:53:35 +02:00

209 lines
8.1 KiB
TypeScript

import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
import { ApplicationFollowUp, ApplicationInterviewPrep } from "./components/InterviewPrep";
import { api } from "./api";
import { I18nProvider } from "./i18n/I18nProvider";
import { ConfirmProvider } from "./confirm";
const render = (ui: React.ReactElement) => rtlRender(<I18nProvider><ConfirmProvider>{ui}</ConfirmProvider></I18nProvider>);
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
patch: 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 board = {
groups: [
{
category: "company-research",
label: "Company research",
items: [
{ id: 1, category: "company-research", title: "Funding history", content: "Series B in 2025.", source: "user", isPrepared: true, sortOrder: 1, updatedAtUtc: "2026-07-19T10:00:00Z" },
],
},
{
category: "behavioural",
label: "Behavioural questions",
items: [
{ id: 2, category: "behavioural", title: "Tell me about a conflict", content: null, source: "ai", isPrepared: false, sortOrder: 2, updatedAtUtc: "2026-07-19T10:00:00Z" },
],
},
],
total: 2,
prepared: 1,
percent: 50,
isInterviewStage: true,
aiSuggestionCount: 1,
};
const followUp = { followUpAt: "2026-07-26T00:00:00", nextAction: "Chase recruiter", responseReceived: false, openFollowUpTasks: 1 };
function routeGet(overrides: Record<string, any> = {}) {
mockedApi.get.mockImplementation((url: string) => {
if (url.endsWith("/follow-up")) return Promise.resolve({ data: overrides.followUp ?? followUp } as any);
if (url.endsWith("/cv")) return Promise.resolve({ data: overrides.cv ?? { attachedVariantId: 3, attachedVariantName: "Backend CV" } } as any);
return Promise.resolve({ data: overrides.board ?? board } as any);
});
}
beforeEach(() => jest.clearAllMocks());
test("prep renders grouped items with progress and marks AI-sourced ones", async () => {
routeGet();
render(<ApplicationInterviewPrep jobId={7} />);
// Each label appears twice: once as the group heading, once as a category option in the add form.
expect((await screen.findAllByText("Company research")).length).toBeGreaterThan(0);
expect(screen.getAllByText("Behavioural questions").length).toBeGreaterThan(0);
expect(screen.getByText("Funding history")).toBeInTheDocument();
expect(screen.getByText("1 of 2 ready")).toBeInTheDocument();
expect(screen.getByText("From AI")).toBeInTheDocument();
});
test("adding a prep item posts the chosen category and title", async () => {
routeGet();
mockedApi.post.mockResolvedValue({ data: board.groups[0].items[0] } as any);
render(<ApplicationInterviewPrep jobId={7} />);
fireEvent.change(await screen.findByLabelText(/Add a question, topic or note/i), {
target: { value: "What does success look like?" },
});
fireEvent.click(screen.getByRole("button", { name: "Add" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
"/jobapplications/7/interview-prep",
{ category: "company-research", title: "What does success look like?" },
));
});
test("marking an item ready patches it", async () => {
routeGet();
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);
render(<ApplicationInterviewPrep jobId={7} />);
fireEvent.click(await screen.findByRole("checkbox", { name: "Ready: Tell me about a conflict" }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith(
"/jobapplications/7/interview-prep/2", { isPrepared: true }));
});
test("an answer is only saved when the user asks", async () => {
routeGet();
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);
render(<ApplicationInterviewPrep jobId={7} />);
const boxes = await screen.findAllByPlaceholderText(/Your answer, in your own words/i);
fireEvent.change(boxes[1], { target: { value: "My STAR answer" } });
// Typing alone must not persist anything.
expect(mockedApi.patch).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: /Save answer/i }));
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith(
"/jobapplications/7/interview-prep/2", { content: "My STAR answer" }));
});
test("deleting a prep item calls delete", async () => {
routeGet();
mockedApi.delete.mockResolvedValue({ data: undefined } as any);
render(<ApplicationInterviewPrep jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: "Delete: Funding history" }));
fireEvent.click(await screen.findByRole("button", { name: "Delete" }));
await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/jobapplications/7/interview-prep/1"));
});
test("empty prep shows a useful empty state", async () => {
routeGet({ board: { groups: [], total: 0, prepared: 0, percent: 0, isInterviewStage: false, aiSuggestionCount: 0 } });
render(<ApplicationInterviewPrep jobId={7} />);
expect(await screen.findByText(/Nothing prepared yet/i)).toBeInTheDocument();
expect(screen.getByText(/has not reached an interview stage/i)).toBeInTheDocument();
});
test("AI preparation uses the linked CV and only saves after explicit acceptance", async () => {
routeGet();
mockedApi.post.mockImplementation((url: string) => {
if (url.endsWith("/ai/generate")) {
return Promise.resolve({ data: { result: { text: "## Technical\nExplain dependency injection." } } } as any);
}
return Promise.resolve({ data: board.groups[0].items[0] } as any);
});
render(<ApplicationInterviewPrep jobId={7} />);
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Generate preparation" }));
expect(await screen.findByText(/Explain dependency injection/i)).toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledWith(
"/jobapplications/7/ai/generate",
expect.objectContaining({ module: "interview", targetLanguage: "en" }),
);
expect(mockedApi.post).not.toHaveBeenCalledWith(
"/jobapplications/7/interview-prep",
expect.anything(),
);
fireEvent.click(screen.getByRole("button", { name: "Add to preparation" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
"/jobapplications/7/interview-prep",
expect.objectContaining({ source: "ai", content: expect.stringContaining("dependency injection") }),
));
});
test("prep surfaces a load error", async () => {
mockedApi.get.mockRejectedValue(new Error("boom"));
render(<ApplicationInterviewPrep jobId={7} />);
expect(await screen.findByText(/Could not load interview preparation/i)).toBeInTheDocument();
});
// ---------- follow-up ----------
test("follow-up loads the existing date and open checklist tasks", async () => {
routeGet();
render(<ApplicationFollowUp jobId={7} />);
expect(await screen.findByDisplayValue("2026-07-26")).toBeInTheDocument();
expect(screen.getByDisplayValue("Chase recruiter")).toBeInTheDocument();
expect(screen.getByText(/1 open follow-up task on the checklist/i)).toBeInTheDocument();
});
test("saving a follow-up sends the date and next action", async () => {
routeGet();
mockedApi.put.mockResolvedValue({ data: followUp } as any);
render(<ApplicationFollowUp jobId={7} />);
fireEvent.change(await screen.findByLabelText(/Follow up on/i), { target: { value: "2026-08-01" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith(
"/jobapplications/7/follow-up",
{ followUpAt: "2026-08-01", nextAction: "Chase recruiter" },
));
});
test("no follow-up shows an empty state", async () => {
routeGet({ followUp: { followUpAt: null, nextAction: null, responseReceived: false, openFollowUpTasks: 0 } });
render(<ApplicationFollowUp jobId={7} />);
expect(await screen.findByText(/No follow-up scheduled/i)).toBeInTheDocument();
});