refactor(jobs): remove legacy details dialog

The routed job workspace owns analysis, correspondence, timeline, and interview flows. Remove the unreachable duplicate dialog and keep coverage on the live components and route contracts.
This commit is contained in:
cesnimda
2026-08-30 23:22:40 +02:00
parent 15cb336208
commit 32d16089f1
9 changed files with 7 additions and 2239 deletions
@@ -3,9 +3,8 @@ import path from "node:path";
test("application panels use distinct timeline, interview board, and generated brief routes", () => { test("application panels use distinct timeline, interview board, and generated brief routes", () => {
const workspace = fs.readFileSync(path.join(process.cwd(), "src/applicationWorkspace.ts"), "utf8"); const workspace = fs.readFileSync(path.join(process.cwd(), "src/applicationWorkspace.ts"), "utf8");
const details = fs.readFileSync(path.join(process.cwd(), "src/components/JobDetailsDialog.tsx"), "utf8");
expect(workspace).toContain("/timeline"); expect(workspace).toContain("/timeline");
expect(workspace).toContain("/interview-prep`"); expect(workspace).toContain("/interview-prep`");
expect(details).toContain("/interview-prep/brief`"); expect(workspace).not.toContain("/interview-prep/brief`");
}); });
File diff suppressed because it is too large Load Diff
@@ -1,134 +1,12 @@
import React from "react"; import React from "react";
import { Box, Button, Chip, CircularProgress, TextField, Typography } from "@mui/material"; import { Box, Button, TextField, Typography } from "@mui/material";
import { useI18n } from "../i18n/I18nProvider"; import { useI18n } from "../i18n/I18nProvider";
import { MatchScore } from "../types";
function copyLines(items: string[]) { function copyLines(items: string[]) {
void navigator.clipboard.writeText(items.join("\n")); void navigator.clipboard.writeText(items.join("\n"));
} }
export function MatchScoreCard({ score, loading, onUpdateLearningRecommendation }: {
score: MatchScore | null;
loading: boolean;
onUpdateLearningRecommendation?: (id: number, status: "done" | "dismissed") => void;
}) {
const { t } = useI18n();
if (loading && !score) {
return (
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", display: "flex", alignItems: "center", gap: 1.5 }}>
<CircularProgress size={18} />
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreLoading")}</Typography>
</Box>
);
}
if (!score) return null;
const color: "success" | "warning" | "error" | "inherit" =
!score.hasEnoughSignal ? "inherit" : score.score >= 75 ? "success" : score.score >= 50 ? "warning" : "error";
const bandLabel = t(`matchScoreBand_${score.band}` as any) || score.band;
const learningRecommendations = score.learningRecommendations ?? [];
const visibleRecommendations = learningRecommendations.filter(item => item.status !== "dismissed");
const dismissedCount = learningRecommendations.length - visibleRecommendations.length;
return (
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
{score.hasEnoughSignal ? (
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
<CircularProgress
variant="determinate"
value={score.score}
size={92}
thickness={4}
aria-hidden="true"
color={color === "inherit" ? "primary" : color}
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
/>
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
</Box>
</Box>
) : (
<Typography variant="h4" sx={{ fontWeight: 800 }}></Typography>
)}
<Box sx={{ flex: 1, minWidth: 200 }}>
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
</Box>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
</Box>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
<Box>
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.matchedKeywords.length ? score.matchedKeywords.map((k) => <Chip key={k} label={k} color="success" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreNoneYet")}</Typography>}
</Box>
</Box>
<Box>
<Typography variant="overline">{t("matchScoreMissing")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.missingKeywords.length ? score.missingKeywords.map((k) => <Chip key={k} label={k} color="warning" variant="outlined" size="small" />) : <Typography variant="body2" sx={{ color: "text.secondary" }}>{t("matchScoreAllCovered")}</Typography>}
</Box>
</Box>
</Box>
{score.sectionCoverage.length ? (
<Box sx={{ mt: 1.5 }}>
<Typography variant="overline">{t("matchScoreSectionCoverage")}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.5 }}>
{score.sectionCoverage.map((s) => <Chip key={s.section} size="small" variant="outlined" label={`${s.section}: ${s.matched}/${s.total}`} />)}
</Box>
</Box>
) : null}
{learningRecommendations.length ? (
<Box sx={{ mt: 1.5 }}>
<Typography variant="overline">{t("matchScoreLearningPath")}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>
{t("matchScoreLearningPathHint")}
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
{visibleRecommendations.map(item => (
<Box key={item.id} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" label={item.keyword} color={item.status === "done" ? "success" : "warning"} variant={item.status === "done" ? "filled" : "outlined"} />
{item.status === "done" ? <Typography variant="caption" color="success.main">{t("matchScoreLearned")}</Typography> : (
<Box sx={{ display: "flex", gap: 0.75 }}>
<Button size="small" onClick={() => onUpdateLearningRecommendation?.(item.id, "done")}>{t("matchScoreMarkLearned")}</Button>
<Button size="small" color="inherit" onClick={() => onUpdateLearningRecommendation?.(item.id, "dismissed")}>{t("matchScoreDismiss")}</Button>
</Box>
)}
</Box>
))}
{dismissedCount ? <Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreDismissedCount", { count: dismissedCount })}</Typography> : null}
</Box>
</Box>
) : null}
</Box>
);
}
export function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) {
const { t } = useI18n();
return (
<Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="overline">{title}</Typography>
<Button size="small" variant="outlined" onClick={() => copyLines(items)}>{t("jobDetailsCopy")}</Button>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.5 }}>
{items.length ? items.map((item) => <Chip key={item} label={item} color={color} variant={outlined ? "outlined" : "filled"} size="small" />) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNothingHighlighted")}</Typography>}
</Box>
</Box>
);
}
export function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) { export function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) {
return ( return (
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}> <Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
@@ -157,23 +35,6 @@ export function ListCard({ title, items, subtitle }: { title: string; items: str
); );
} }
export function WorkspaceDraftCard({ title, value, onChange, statusLabel, statusColor }: { title: string; value: string; onChange: (value: string) => void; statusLabel: string; statusColor: "default" | "success" | "warning" }) {
const { t } = useI18n();
return (
<Box sx={{ p: 1.5, borderRadius: 3, boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
<Typography variant="overline">{title}</Typography>
<Box sx={{ display: "flex", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
<Chip size="small" color={statusColor} label={statusLabel} />
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(value)}>{t("jobDetailsCopy")}</Button>
</Box>
</Box>
<TextField value={value} onChange={(e) => onChange(e.target.value)} multiline minRows={7} fullWidth />
</Box>
);
}
export function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) { export function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
const { t } = useI18n(); const { t } = useI18n();
const [value, setValue] = React.useState(content); const [value, setValue] = React.useState(content);
@@ -195,18 +56,3 @@ export function DraftCard({ title, content, onSave, saving }: { title: string; c
</Box> </Box>
); );
} }
export function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) {
return (
<Box sx={{ border: "1px solid rgba(15,23,42,0.08)", borderRadius: 2, p: 1.25, background: "rgba(255,255,255,0.6)" }}>
<Typography sx={{ fontWeight: 900, lineHeight: 1.25 }}>
{type}
{oldValue || newValue ? <span style={{ fontWeight: 700, opacity: 0.7 }}>{" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""})</span> : null}
</Typography>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{at ? new Date(at).toLocaleString() : ""}
{note ? ` - ${note}` : ""}
</Typography>
</Box>
);
}
@@ -1,143 +0,0 @@
import React from "react";
import { Alert, Box, Button, Chip, CircularProgress, Typography } from "@mui/material";
import { useI18n } from "../i18n/I18nProvider";
import { CandidateFit, FocusPlanResponse, InterviewPrepResponse, MatchScore, ReadinessResponse, UserOperation } from "../types";
import { DraftCard, ListCard, MatchScoreCard, SectionChips, TwoColumnSection } from "./JobDetailsPanels";
type Props = {
tab: number;
matchScore: MatchScore | null;
loadingMatchScore: boolean;
updateLearningRecommendation: (id: number, status: "done" | "dismissed") => void;
candidateFit: CandidateFit | null;
loadingCandidateFit: boolean;
regenerateCandidateFit: () => void;
fitLevel: { label: string; color: "success" | "warning" | "default" } | null;
focusPlan: FocusPlanResponse | null;
loadingFocusPlan: boolean;
regenerateFocusPlan: () => void;
focusPlanOperation: UserOperation | null;
cancelFocusPlan: () => void;
retryFocusPlan: () => void;
interviewPrep: InterviewPrepResponse | null;
loadingInterviewPrep: boolean;
regenerateInterviewPrep: () => void;
readiness: ReadinessResponse | null;
loadingReadiness: boolean;
};
export default function JobInsightTabs(props: Props) {
const { t } = useI18n();
const { tab, matchScore, loadingMatchScore, updateLearningRecommendation, candidateFit, loadingCandidateFit, regenerateCandidateFit, fitLevel, focusPlan, loadingFocusPlan, regenerateFocusPlan, focusPlanOperation, cancelFocusPlan, retryFocusPlan, interviewPrep, loadingInterviewPrep, regenerateInterviewPrep, readiness, loadingReadiness } = props;
return <>
{tab === 5 && (
<Box>
<MatchScoreCard score={matchScore} loading={loadingMatchScore} onUpdateLearningRecommendation={updateLearningRecommendation} />
<Box sx={{ display: "flex", justifyContent: "flex-end", my: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingCandidateFit} onClick={regenerateCandidateFit}>
{loadingCandidateFit ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Chip label={t("jobDetailsMatchPercent", { count: candidateFit.matchScore })} color={candidateFit.matchScore >= 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" />
{fitLevel ? <Chip label={fitLevel.label} color={fitLevel.color} size="small" /> : null}
</Box>
</Box>
<DraftCard title={t("jobDetailsTailoredPitch")} content={candidateFit.tailoredPitch} />
<SectionChips title={t("jobDetailsStrongMatches")} items={candidateFit.strengths} color="success" />
<SectionChips title={t("jobDetailsPossibleGaps")} items={candidateFit.gaps} color="warning" outlined />
<TwoColumnSection leftTitle={t("jobDetailsWhatToMention")} leftItems={candidateFit.mention} rightTitle={t("jobDetailsWhatNotToOverstate")} rightItems={candidateFit.avoid} />
<TwoColumnSection leftTitle={t("jobDetailsImproveCv")} leftItems={candidateFit.cvImprovements} rightTitle={t("jobDetailsMissingKeywords")} rightItems={candidateFit.missingKeywords} />
<TwoColumnSection leftTitle={t("jobDetailsTabInterviewPrep")} leftItems={candidateFit.interviewPrep} rightTitle={t("jobDetailsCvGuidance")} rightItems={candidateFit.guidance.cv} />
<TwoColumnSection leftTitle={t("jobDetailsCoverLetterGuidance")} leftItems={candidateFit.guidance.coverLetter} rightTitle={t("jobDetailsRecruiterMessageGuidance")} rightItems={candidateFit.guidance.recruiterMessage} />
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<DraftCard title={t("jobDetailsCoverLetterDraft")} content={candidateFit.coverLetterDraft ?? t("jobDetailsNoDraftAvailableYet")} />
<DraftCard title={t("jobDetailsRecruiterMessageDraft")} content={candidateFit.recruiterMessageDraft ?? t("jobDetailsNoDraftAvailableYet")} />
</Box>
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsCandidateFitEmpty")}</Typography>}
</Box>
)}
{tab === 6 && (
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingFocusPlan} onClick={regenerateFocusPlan}>
{loadingFocusPlan ? "Starting..." : focusPlan ? "Regenerate" : "Generate"}
</Button>
</Box>
{focusPlanOperation && focusPlanOperation.status !== "succeeded" ? (
<Alert severity={focusPlanOperation.status === "failed" ? "error" : focusPlanOperation.status === "cancelled" ? "warning" : "info"} sx={{ mb: 2 }}
action={<>
{focusPlanOperation.canCancel ? <Button size="small" color="inherit" onClick={cancelFocusPlan}>Cancel</Button> : null}
{focusPlanOperation.canRetry ? <Button size="small" color="inherit" onClick={retryFocusPlan}>Retry</Button> : null}
</>}>
Strategy snapshot: {operationLabel(focusPlanOperation)}
</Alert>
) : null}
{loadingFocusPlan && !focusPlan ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : focusPlan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsFocusSummary")} content={focusPlan.strategicSummary} />
<TwoColumnSection leftTitle={t("jobDetailsImmediatePriorities")} leftItems={focusPlan.immediatePriorities} rightTitle={t("jobDetailsProofPoints")} rightItems={focusPlan.proofPointsToLeadWith} />
<TwoColumnSection leftTitle={t("jobDetailsCvBulletIdeas")} leftItems={focusPlan.cvBulletIdeas} rightTitle={t("jobDetailsCoverLetterAngles")} rightItems={focusPlan.coverLetterAngles} />
<ListCard title={t("jobDetailsFollowUpApproach")} items={focusPlan.followUpApproach} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoFocusPlan")}</Typography>}
</Box>
)}
{tab === 7 && (
<Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1.5 }}>
<Button size="small" variant="outlined" disabled={loadingInterviewPrep} onClick={regenerateInterviewPrep}>
{loadingInterviewPrep ? "Regenerating..." : "Regenerate"}
</Button>
</Box>
{loadingInterviewPrep ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : interviewPrep ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<DraftCard title={t("jobDetailsInterviewPrepBrief")} content={interviewPrep.summary} />
<TwoColumnSection leftTitle={t("jobDetailsTalkingPoints")} leftItems={interviewPrep.talkingPoints} rightTitle={t("jobDetailsLikelyQuestions")} rightItems={interviewPrep.likelyQuestions} />
<ListCard title={t("jobDetailsWeakSpots")} items={interviewPrep.weakSpots} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoInterviewPrep")}</Typography>}
</Box>
)}
{tab === 8 && (
<Box>
{loadingReadiness ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : readiness ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Typography variant="h6">{t("jobDetailsApplicationReadiness")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip label={t("jobDetailsReadyPercent", { count: readiness.score })} color={readiness.score >= 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} />
<Chip label={readiness.level} variant="outlined" />
</Box>
</Box>
<TwoColumnSection leftTitle={t("jobDetailsCompleted")} leftItems={readiness.completed} rightTitle={t("jobDetailsStillMissing")} rightItems={readiness.missing} />
<ListCard title={t("jobDetailsSmartReminders")} items={readiness.reminders} />
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoReadiness")}</Typography>}
</Box>
)}
</>;
}
function operationLabel(operation: UserOperation) {
if (operation.cancellationRequestedAtUtc) return "cancellation requested";
switch (operation.status) {
case "queued": return "queued";
case "running": return "processing locally";
case "waiting_for_retry": return "waiting to retry";
case "waiting_for_external_fallback": return "waiting for approved fallback";
case "failed": return `failed${operation.failureCategory ? ` (${operation.failureCategory.replaceAll("_", " ")})` : ""}`;
case "cancelled": return "cancelled";
default: return "completed";
}
}
@@ -5,7 +5,7 @@ import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt"; import { PromptProvider } from "./prompt";
import { ToastProvider } from "./toast"; import { ToastProvider } from "./toast";
import { I18nProvider } from "./i18n/I18nProvider"; import { I18nProvider } from "./i18n/I18nProvider";
import JobDetailsDialog from "./components/JobDetailsDialog"; import Correspondence from "./components/Correspondence";
import { api } from "./api"; import { api } from "./api";
jest.setTimeout(15000); jest.setTimeout(15000);
@@ -29,7 +29,10 @@ function renderDialog() {
<I18nProvider> <I18nProvider>
<ConfirmProvider> <ConfirmProvider>
<PromptProvider> <PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={1} /> <Correspondence
jobId={42}
jobContext={{ jobTitle: "Backend Developer", companyName: "Acme", recruiterEmail: "maria@acme.test" }}
/>
</PromptProvider> </PromptProvider>
</ConfirmProvider> </ConfirmProvider>
</I18nProvider> </I18nProvider>
@@ -1,113 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobDetailsDialog from './components/JobDetailsDialog';
import { api } from './api';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: {
id: 42,
jobTitle: 'Backend Developer',
status: 'Waiting',
dateApplied: new Date().toISOString(),
daysSince: 10,
company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' },
tailoredCvText: 'Saved CV',
coverLetterText: 'Saved cover letter',
recruiterMessageDraft: 'Saved recruiter message',
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved application answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
shortSummary: 'summary'
} } as any);
}
if (url === '/auth/me') {
return Promise.resolve({ data: { roles: [], profileCvText: 'Master CV text' } } as any);
}
if (url === '/jobapplications/42/history') {
return Promise.resolve({ data: [] } as any);
}
if (url === '/attachments/42') {
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
}
if (url === '/jobapplications/42/followup-draft') {
return Promise.resolve({ data: {
subject: 'Re: Backend Developer application update',
body: 'Hi Maria,\n\nI wanted to follow up on the Backend Developer thread and reiterate my fit for owning the API layer.\n\nThanks,\nCasey',
reason: 'Scheduled follow-up is due.',
suggestedSendOn: new Date().toISOString(),
contextSummary: 'Scheduled follow-up is due. Latest thread activity was on March 10, 2026. Saved application package material is available for reuse.',
contextSignals: ['Saved cover letter available', 'Saved tailored CV available', 'Thread participants: Maria Recruiter <maria@acme.test>, user@example.test'],
threadSubject: 'Backend Developer application update',
lastCorrespondenceFrom: 'Maria Recruiter <maria@acme.test>',
lastCorrespondenceAt: new Date().toISOString(),
} } as any);
}
return Promise.resolve({ data: [] } as any);
});
mockedApi.post.mockResolvedValue({ data: {} } as any);
mockedApi.put.mockResolvedValue({ data: {} } as any);
});
afterEach(() => {
jest.clearAllMocks();
});
test('follow-up workspace keeps the draft editable and routes delivery to Job email', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /follow up/i }));
expect(await screen.findByText(/follow-up context/i)).toBeInTheDocument();
expect(await screen.findByText(/saved application package material is available for reuse/i)).toBeInTheDocument();
expect(await screen.findByText(/saved cover letter available/i)).toBeInTheDocument();
expect(await screen.findByText(/manual send boundary/i)).toBeInTheDocument();
expect(await screen.findByText(/open job email.*connected provider.*final confirmation/i)).toBeInTheDocument();
expect(await screen.findByDisplayValue(/i wanted to follow up on the backend developer thread/i)).toBeInTheDocument();
const draft = screen.getByDisplayValue(/i wanted to follow up on the backend developer thread/i);
fireEvent.change(draft, { target: { value: 'Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey' } });
expect(draft).toHaveValue('Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey');
expect(screen.getByRole('link', { name: /open job email/i })).toHaveAttribute('href', '/correspondence');
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobapplications/42/send-followup', expect.anything());
});
@@ -1,306 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobDetailsDialog from './components/JobDetailsDialog';
import { api } from './api';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
Object.assign(URL, {
createObjectURL: jest.fn().mockReturnValue('blob:preview-pdf'),
revokeObjectURL: jest.fn(),
});
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: {
id: 42,
jobTitle: 'Backend Developer',
status: 'Applied',
dateApplied: new Date().toISOString(),
daysSince: 3,
company: { name: 'Acme', recruiterEmail: 'recruiter@acme.test' },
tailoredCvText: 'Saved CV',
coverLetterText: 'Saved cover letter',
recruiterMessageDraft: 'Saved recruiter message',
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nSaved application answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
shortSummary: 'summary'
} } as any);
}
if (url === '/auth/me') {
return Promise.resolve({ data: { roles: [], avatarImageDataUrl: 'data:image/png;base64,avatar123' } } as any);
}
if (url === '/jobapplications/42/history') {
return Promise.resolve({ data: [] } as any);
}
if (url === '/attachments/42') {
return Promise.resolve({ data: [{ id: 9, fileName: 'resume.pdf', uploadDate: new Date().toISOString(), fileType: 'application/pdf', fileSize: 1234, purpose: 'resume', useForAi: true }] } as any);
}
if (url === '/jobapplications/42/tailored-cv-draft') {
return Promise.resolve({ data: {
id: 5,
canonicalProfileVersion: 3,
templateId: 'ats-minimal',
headline: 'Backend Engineer',
summary: ['Built APIs', 'Shipped backend work'],
selectedSkills: ['.NET', 'SQL'],
experience: [],
education: [],
customSections: [],
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
generationContextHash: 'abc123',
lastGeneratedAtUtc: new Date().toISOString(),
lastEditedAtUtc: null,
status: 'generated',
renderedText: 'Backend Engineer\n\nProfessional Summary\n- Built APIs\n- Shipped backend work',
isLegacyFallback: false,
} } as any);
}
if (url === '/jobapplications/42/candidate-fit') {
return Promise.resolve({ data: { matchSummary: 'Strong fit summary', fitLevel: 'Strong match', matchScore: 84, strengths: ['.NET'], gaps: ['Kubernetes'], mention: [], avoid: [], cvImprovements: [], missingKeywords: [], interviewPrep: [], tailoredPitch: 'Pitch', guidance: { cv: [], coverLetter: [], interview: [], recruiterMessage: [] } } } as any);
}
if (url === '/jobapplications/42/focus-plan') {
return Promise.resolve({ data: { strategicSummary: 'Lead with backend delivery and measurable outcomes.', immediatePriorities: ['Highlight .NET ownership'], cvBulletIdeas: [], proofPointsToLeadWith: [], coverLetterAngles: [], followUpApproach: [] } } as any);
}
if (url === '/jobapplications/42/focus-plan/operation') {
return Promise.reject({ response: { status: 404 } });
}
return Promise.resolve({ data: [] } as any);
});
mockedApi.post.mockImplementation((url: string, body?: any, config?: any) => {
if (url === '/jobapplications/42/generate-tailored-cv-draft') {
return Promise.resolve({ data: {
id: 5,
canonicalProfileVersion: 3,
templateId: 'ats-minimal',
headline: 'Senior Backend Engineer',
summary: ['Owned API delivery', 'Improved SQL workflows'],
selectedSkills: ['.NET', 'SQL', 'APIs'],
experience: [],
education: [],
customSections: [],
renderOptions: { showPhoto: false, pageMode: 'one-page', accentColor: 'slate', sectionOrder: ['summary', 'skills', 'experience', 'education', 'custom'], bulletDensity: 'balanced' },
generationContextHash: 'def456',
lastGeneratedAtUtc: new Date().toISOString(),
lastEditedAtUtc: null,
status: 'generated',
renderedText: 'Senior Backend Engineer\n\nProfessional Summary\n- Owned API delivery\n- Improved SQL workflows',
isLegacyFallback: false,
} } as any);
}
if (url === '/jobapplications/42/tailored-cv-preview') {
return Promise.resolve({ data: {
templateId: body?.templateId ?? 'ats-minimal',
suggestedFileName: `${body?.templateId ?? 'ats-minimal'}.pdf`,
html: `<html><body data-template="${body?.templateId ?? 'ats-minimal'}" data-accent="${body?.renderOptions?.accentColor ?? ''}" data-photo="${body?.useProfileAvatar ? 'profile' : 'custom'}"></body></html>`,
} } as any);
}
if (url === '/jobapplications/42/export-tailored-cv-pdf') {
return Promise.resolve({ data: new Blob(['pdf'], { type: 'application/pdf' }) } as any);
}
if (url === '/jobapplications/42/generate-application-package') {
return Promise.resolve({ data: { tailoredCvText: 'Generated package CV', coverLetterDraft: 'Draft letter', applicationAnswerDraft: 'Draft answer', recruiterMessageDraft: 'Recruiter hello', keyPoints: ['Lead with .NET'], attachmentSignals: [], attachmentFilesUsed: [], coverLetterVariants: ['Variant A'], recruiterMessageVariants: ['Variant B'] } } as any);
}
if (url === '/jobapplications/42/focus-plan/operations') {
return Promise.resolve({ data: { created: true, statusUrl: '/api/operations/strategy-1', operation: {
id: 'strategy-1', taskType: 'strategy.snapshot', status: 'succeeded', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: false, canRetry: false,
} } } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
});
afterEach(() => {
jest.clearAllMocks();
});
test('tailored cv tab loads, regenerates, and saves the structured tailored draft', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
expect(await screen.findByDisplayValue('Backend Engineer')).toBeInTheDocument();
expect((await screen.findByLabelText('Summary bullets')) as HTMLInputElement).toHaveValue('Built APIs\nShipped backend work');
fireEvent.click(screen.getByRole('button', { name: /generate tailored draft/i }));
expect(await screen.findByDisplayValue('Senior Backend Engineer')).toBeInTheDocument();
const headline = screen.getByDisplayValue('Senior Backend Engineer');
fireEvent.change(headline, { target: { value: 'Principal Backend Engineer' } });
fireEvent.click(screen.getByRole('button', { name: /save tailored draft/i }));
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-draft', expect.objectContaining({
headline: 'Principal Backend Engineer',
summary: ['Owned API delivery', 'Improved SQL workflows'],
selectedSkills: ['.NET', 'SQL', 'APIs'],
status: 'edited',
}));
});
expect(mockedApi.put).not.toHaveBeenCalledWith('/jobapplications/42/tailored-cv', expect.anything());
});
test('application package drafts save separately from the tailored cv draft', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
fireEvent.click(await screen.findByRole('button', { name: /generate application package/i }));
const coverLetter = await screen.findByDisplayValue('Draft letter');
const applicationAnswer = await screen.findByDisplayValue('Draft answer');
const recruiterMessage = await screen.findByDisplayValue('Recruiter hello');
fireEvent.change(coverLetter, { target: { value: 'Edited cover letter' } });
fireEvent.change(applicationAnswer, { target: { value: 'Edited answer' } });
fireEvent.change(recruiterMessage, { target: { value: 'Edited recruiter note' } });
fireEvent.click(screen.getByRole('button', { name: /save package drafts/i }));
await waitFor(() => {
expect(mockedApi.put).toHaveBeenCalledWith('/jobapplications/42/application-drafts', {
coverLetterText: 'Edited cover letter',
notes: 'Original notes\n\n<<<APPLICATION_ANSWER_DRAFT>>>\nEdited answer\n<<<END_APPLICATION_ANSWER_DRAFT>>>',
recruiterMessageDraft: 'Edited recruiter note',
});
});
});
test('template switching refreshes preview and export uses the selected template payload', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /tailored cv/i }));
const comboboxes = await screen.findAllByRole('combobox');
fireEvent.mouseDown(comboboxes[1]);
fireEvent.click(await screen.findByRole('option', { name: 'Harvard' }));
const accent = screen.getByLabelText('Accent');
fireEvent.change(accent, { target: { value: '#123456' } });
fireEvent.click(screen.getByRole('button', { name: /preview pdf layout/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/tailored-cv-preview', expect.objectContaining({
templateId: 'harvard',
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
useProfileAvatar: true,
}));
});
expect(await screen.findByTitle('Tailored CV preview')).toHaveAttribute('sandbox', 'allow-same-origin');
const appendChildSpy = jest.spyOn(document.body, 'appendChild');
const removeSpy = jest.spyOn(HTMLAnchorElement.prototype, 'remove').mockImplementation(() => {});
const clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
fireEvent.click(screen.getByRole('button', { name: /download pdf/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/export-tailored-cv-pdf', expect.objectContaining({
templateId: 'harvard',
renderOptions: expect.objectContaining({ accentColor: '#123456' }),
}), expect.objectContaining({ responseType: 'blob' }));
expect(URL.createObjectURL).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
});
appendChildSpy.mockRestore();
removeSpy.mockRestore();
clickSpy.mockRestore();
});
test('strategy snapshot can be generated from overview', async () => {
renderDialog();
expect(await screen.findByRole('button', { name: /generate strategy snapshot/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /generate strategy snapshot/i }));
expect(await screen.findByText(/lead with backend delivery and measurable outcomes/i)).toBeInTheDocument();
expect(await screen.findByText(/highlight \.net ownership/i)).toBeInTheDocument();
});
test('strategy snapshot exposes queued and cancelled durable states', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/jobapplications/42/focus-plan/operations') {
return Promise.resolve({ data: { created: true, statusUrl: '/api/operations/strategy-queued', operation: {
id: 'strategy-queued', taskType: 'strategy.snapshot', status: 'queued', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: true, canRetry: false,
} } } as any);
}
if (url === '/operations/strategy-queued/cancel') {
return Promise.resolve({ data: {
id: 'strategy-queued', taskType: 'strategy.snapshot', status: 'cancelled', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: false, canRetry: true,
} } as any);
}
return Promise.resolve({ data: {} } as any);
});
renderDialog();
fireEvent.click(await screen.findByRole('button', { name: /generate strategy snapshot/i }));
expect(await screen.findByText(/strategy snapshot: queued/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(await screen.findByText(/strategy snapshot: cancelled/i)).toBeInTheDocument();
});
test('failed strategy snapshot offers a safe retry', async () => {
mockedApi.post.mockImplementation((url: string) => {
if (url === '/jobapplications/42/focus-plan/operations') {
return Promise.resolve({ data: { created: false, statusUrl: '/api/operations/strategy-failed', operation: {
id: 'strategy-failed', taskType: 'strategy.snapshot', status: 'failed', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), failureCategory: 'provider_unavailable', canCancel: false, canRetry: true,
} } } as any);
}
if (url === '/operations/strategy-failed/retry') {
return Promise.resolve({ data: {
id: 'strategy-failed', taskType: 'strategy.snapshot', status: 'queued', subjectType: 'job_strategy', createdAtUtc: new Date().toISOString(), canCancel: true, canRetry: false,
} } as any);
}
return Promise.resolve({ data: {} } as any);
});
renderDialog();
fireEvent.click(await screen.findByRole('button', { name: /generate strategy snapshot/i }));
expect(await screen.findByText(/strategy snapshot: failed.*provider unavailable/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /retry/i }));
expect(await screen.findByText(/strategy snapshot: queued/i)).toBeInTheDocument();
});
@@ -1,132 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobDetailsDialog from './components/JobDetailsDialog';
import { api } from './api';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
const matchScore = {
score: 82,
band: 'Strong',
matchedCount: 4,
totalKeywords: 6,
matchedKeywords: ['C#', '.NET', 'SQL', 'Docker'],
missingKeywords: ['Kubernetes', 'GraphQL'],
sectionCoverage: [
{ section: 'Skills', matched: 4, total: 6 },
{ section: 'Experience', matched: 3, total: 6 },
],
hasEnoughSignal: true,
learningRecommendations: [
{ id: 71, keyword: 'Kubernetes', status: 'pending' },
{ id: 72, keyword: 'GraphQL', status: 'pending' },
],
};
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={5} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/match-score') {
return Promise.resolve({ data: matchScore } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
// Candidate-fit AI narrative: leave pending-ish/empty so we only assert on the fast panel.
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('match score panel shows the score and honest important-term labels', async () => {
renderDialog();
expect(await screen.findByText('82%')).toBeInTheDocument();
expect(await screen.findByText(/strong match/i)).toBeInTheDocument();
expect(await screen.findByText('4/6 important terms')).toBeInTheDocument();
expect(await screen.findByText('Important terms already in your CV')).toBeInTheDocument();
expect(await screen.findByText('Important terms from the job to review')).toBeInTheDocument();
expect(screen.queryByText(/matched keywords/i)).not.toBeInTheDocument();
// Matched keyword chips
expect(await screen.findByText('C#')).toBeInTheDocument();
expect(await screen.findByText('Docker')).toBeInTheDocument();
// Missing keyword chips
expect((await screen.findAllByText('Kubernetes')).length).toBeGreaterThan(0);
expect((await screen.findAllByText('GraphQL')).length).toBeGreaterThan(0);
// Section coverage
expect(await screen.findByText('Skills: 4/6')).toBeInTheDocument();
});
test('learning recommendations can be completed or dismissed', async () => {
renderDialog();
fireEvent.click((await screen.findAllByRole('button', { name: /mark learned/i }))[0]);
expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/checklist/71', { status: 'done' });
expect(await screen.findByText('Learned')).toBeInTheDocument();
fireEvent.click(screen.getAllByRole('button', { name: /dismiss/i })[0]);
expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/checklist/72', { status: 'dismissed' });
expect(await screen.findByText('1 dismissed')).toBeInTheDocument();
});
test('match score panel degrades gracefully when there is not enough signal', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/match-score') {
return Promise.resolve({ data: { ...matchScore, score: 0, band: 'Unknown', matchedCount: 0, matchedKeywords: [], missingKeywords: [], sectionCoverage: [], hasEnoughSignal: false } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
if (url === '/jobapplications/42/candidate-fit') return Promise.resolve({ data: null } as any);
return Promise.resolve({ data: {} } as any);
});
renderDialog();
expect(await screen.findByText('—')).toBeInTheDocument();
expect(await screen.findByText(/not enough signal/i)).toBeInTheDocument();
});
@@ -1,90 +0,0 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobDetailsDialog from './components/JobDetailsDialog';
import { api } from './api';
jest.setTimeout(15000);
jest.mock('./api', () => ({
api: {
get: jest.fn(),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: jest.fn(() => 'error'),
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/status-suggestion') {
return Promise.resolve({ data: { hasSuggestion: true, suggestedStatus: 'Interview', currentStatus: 'Applied', signal: 'schedule an interview', confidence: 'medium' } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('status suggestion banner appears and applies via PATCH', async () => {
renderDialog();
expect(await screen.findByText(/looks like a move to interview/i)).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: /move to interview/i }));
await waitFor(() => {
expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' });
});
});
test('no banner when there is no suggestion', async () => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/jobapplications/42') {
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
}
if (url === '/jobapplications/42/status-suggestion') {
return Promise.resolve({ data: { hasSuggestion: false } } as any);
}
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
return Promise.resolve({ data: {} } as any);
});
renderDialog();
expect(await screen.findByText(/backend developer/i)).toBeInTheDocument();
expect(screen.queryByText(/looks like a move to/i)).not.toBeInTheDocument();
});