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({ui}); 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; 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 = {}) { 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(); // 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(await screen.findByText(/No follow-up scheduled/i)).toBeInTheDocument(); });