3d74baef78
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase. Interview preparation gets a durable, user-owned store. There were already two per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are caches that regenerate when their context signature changes — anything a user typed into them would eventually be overwritten. InterviewPrepItem is the side nothing regenerates, covering company research, technical notes, behavioural answers, STAR examples and the user's own questions in one table, because those categories differ only by label and adding one must not need a migration. Each item records whether the user wrote it or accepted a suggestion, and an IsPrepared flag makes the section double as the preparation checklist. Generation stays in the existing AiWorkspaceService "interview" module, appended to AiInteraction as before. A suggestion is history until the user adds it as a prep item; opening the section generates nothing. Follow-up reuses what exists rather than adding a tracker. The date is JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted service already act on, so reminders keep working with no new wiring. The task stays an ApplicationChecklistItem in the follow-up category — the section counts open tasks without owning them. The record is a FollowUpSet JobEvent, the same type the rest of the app emits. Communication is untouched: Correspondence already owns recruiter contacts, history and notes, and the workspace already mounted it. The timeline interpreter learned five more types — InterviewScheduled, InterviewCompleted and OfferReceived as milestones, FollowUpCreated and FollowUpCompleted as routine, deliberately outside the milestone spine so it stays a summary of what actually happened. JobEvent remains the history source. InterviewPrepItems is reconciler-owned with a no-op migration, guarded on JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary key, varchar owner and title, tinyint flag, datetime(6), composite index inside the key limit. 371 backend tests, 128 frontend tests, Release build and the production build all pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
173 lines
6.4 KiB
TypeScript
173 lines
6.4 KiB
TypeScript
import React from "react";
|
|
import "@testing-library/jest-dom";
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
|
|
import { ApplicationFollowUp, ApplicationInterviewPrep } from "./components/InterviewPrep";
|
|
import { api } from "./api";
|
|
|
|
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);
|
|
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" }));
|
|
|
|
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("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();
|
|
});
|