209 lines
8.1 KiB
TypeScript
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();
|
|
});
|