feat(S05/T02): Added an end-to-end trust-loop regression, surfaced save…

- job-tracker-ui/src/end-to-end-trust-loop.test.tsx
- job-tracker-ui/src/components/JobDetailsDialog.tsx
- job-tracker-ui/src/components/Correspondence.tsx
- .gsd/milestones/M001/slices/S05/S05-UAT.md
This commit is contained in:
2026-03-24 14:36:46 +01:00
parent 9f631ca320
commit a0d1c1c05b
7 changed files with 436 additions and 2 deletions
@@ -405,6 +405,28 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
)}
</Paper>
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Typography variant="overline">Linked Gmail thread continuity</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.25 }}>
Linked Gmail refresh only checks threads that are already tied to this job, so new correspondence can appear here without re-importing the whole thread.
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
{linkedThreadRefresh ? (
<Chip
size="small"
variant="outlined"
label={linkedThreadRefresh.imported > 0
? `Last linked refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}`
: linkedThreadRefresh.hasLinkedThreads
? `Last linked refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}`
: "No linked Gmail refresh history yet"}
/>
) : null}
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
<ToggleButtonGroup exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
<ToggleButton value="Me">{t("correspondenceMe")}</ToggleButton>
@@ -553,6 +553,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Chip size="small" label={`Cover letter · ${coverLetterStatus.label}`} color={coverLetterStatus.color} />
<Chip size="small" label={`Application answer · ${applicationAnswerStatus.label}`} color={applicationAnswerStatus.color} />
<Chip size="small" label={`Recruiter message · ${recruiterMessageStatus.label}`} color={recruiterMessageStatus.color} />
<Chip size="small" variant="outlined" label="Saved package material feeds follow-up drafting" />
{packageGeneratedAt ? <Chip size="small" variant="outlined" label={`Generated ${new Date(packageGeneratedAt).toLocaleTimeString()}`} /> : null}
</Box>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("jobDetailsTailoredCvIntro")}</Typography>
@@ -584,7 +585,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
/>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
<Typography variant="overline">Saved working material</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what later slices can trust and reuse.</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>These saved copies are what follow-up drafting and later slices can trust and reuse.</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="body2"><strong>Tailored CV:</strong> {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"}</Typography>
<Typography variant="body2"><strong>Cover letter:</strong> {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"}</Typography>
@@ -632,6 +633,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</FormControl>
<Button variant="outlined" onClick={() => setDraftReloadToken((value) => value + 1)}>{t("jobDetailsRegenerateDraft")}</Button>
</Box>
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: "background.default" }}>
<Typography variant="overline">Manual send boundary</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Generating or regenerating this grounded draft never sends recruiter email. The only outbound step is the explicit Send and log email action below.
</Typography>
</Box>
<TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} Manual send only — nothing is dispatched until you press send.`} />
<TextField label={t("jobDetailsSubject")} value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
<TextField label={t("jobDetailsDraft")} multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} helperText="You can edit this before sending. Sending stays manual and logs the sent note back to correspondence." />
@@ -0,0 +1,234 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import JobTable from './components/JobTable';
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>;
const jobRecord = {
id: 42,
jobTitle: 'Backend Developer',
status: 'Waiting',
dateApplied: new Date().toISOString(),
daysSince: 10,
company: { name: 'Acme', recruiterEmail: 'maria@acme.test', recruiterName: 'Maria Recruiter' },
companyId: 1,
needsFollowUp: true,
followUpReason: 'Follow-up due soon',
shortSummary: 'Strong backend match',
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>>>',
workflowSignal: {
actionKey: 'follow-up',
reason: 'Follow-up due soon',
workspaceTab: 'follow-up',
followMode: 'waiting-update',
needsAttention: true,
hasPackageGap: false,
needsInterviewPrep: false,
needsFollowUpAction: true,
hasTailoredCv: true,
hasSavedApplicationAnswerDraft: true,
hasInterviewPrepNotes: false,
},
};
function LocationIndicator() {
const location = useLocation();
return <div data-testid="location-indicator">{location.pathname}{location.search}</div>;
}
function renderLoop(initialPath = '/jobs') {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<MemoryRouter initialEntries={[initialPath]}>
<LocationIndicator />
<Routes>
<Route path="/jobs" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} />
</Routes>
</MemoryRouter>
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
describe('end-to-end trust loop', () => {
let correspondenceMessages: any[];
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
correspondenceMessages = [
{
id: 700,
jobApplicationId: 42,
from: 'Company',
content: 'Acme wants to schedule a call.',
subject: 'Backend Developer interview',
channel: 'Email',
date: new Date().toISOString(),
externalMessageId: 'msg-1',
externalThreadId: 'thread-1',
externalFrom: 'Maria Recruiter <maria@acme.test>',
externalTo: 'user@example.test',
},
];
mockedApi.get.mockImplementation((url: string) => {
if (url === '/companies') return Promise.resolve({ data: [{ id: 1, name: 'Acme' }] } as any);
if (url === '/jobapplications') return Promise.resolve({ data: { items: [jobRecord], total: 1, page: 1, pageSize: 15 } } as any);
if (url === '/jobapplications/42') return Promise.resolve({ data: jobRecord } 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 === '/correspondence/42') return Promise.resolve({ data: correspondenceMessages } as any);
if (url === '/gmail/status') return Promise.resolve({ data: { connected: true, gmailAddress: 'user@example.test', lastSyncedAt: new Date().toISOString() } } 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. 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.mockImplementation((url: string, body?: any) => {
if (url === '/gmail/refresh-linked-threads') {
const hasReply = correspondenceMessages.some((message) => message.externalMessageId === 'msg-2');
if (!hasReply) {
correspondenceMessages = [
...correspondenceMessages,
{
id: 701,
jobApplicationId: 42,
from: 'Me',
content: 'Following up on the role.',
subject: 'Backend Developer follow-up',
channel: 'Email',
date: new Date().toISOString(),
externalMessageId: 'msg-2',
externalThreadId: 'thread-1',
externalFrom: 'user@example.test',
externalTo: 'Maria Recruiter <maria@acme.test>',
},
];
}
return Promise.resolve({
data: {
jobApplicationId: body.jobApplicationId,
threadsChecked: 1,
imported: hasReply ? 0 : 1,
skipped: hasReply ? 2 : 1,
hasLinkedThreads: true,
refreshedAt: new Date().toISOString(),
threads: [
{
threadId: 'thread-1',
imported: hasReply ? 0 : 1,
skipped: hasReply ? 2 : 1,
totalMessages: hasReply ? 2 : 2,
status: hasReply ? 'already-current' : 'imported-new-messages',
latestMessageDate: new Date().toISOString(),
},
],
},
} as any);
}
if (url === '/jobapplications/42/send-followup') {
return Promise.resolve({ data: {} } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
mockedApi.patch.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
});
afterEach(() => {
jest.clearAllMocks();
});
test('overview entry composes saved package reuse, linked-thread continuity, and grounded follow-up drafting without sending mail', async () => {
renderLoop();
fireEvent.click(await screen.findByRole('button', { name: /next action: backend developer/i }));
await waitFor(() => {
expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs');
});
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(/manual send boundary/i)).toBeInTheDocument();
expect(await screen.findByText(/the only outbound step is the explicit “send and log email” action below/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /tailored cv/i }));
expect(await screen.findByDisplayValue('Saved CV')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved cover letter')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved application answer')).toBeInTheDocument();
expect(await screen.findByDisplayValue('Saved recruiter message')).toBeInTheDocument();
expect(await screen.findByText(/saved package material feeds follow-up drafting/i)).toBeInTheDocument();
expect(await screen.findByText(/these saved copies are what follow-up drafting and later slices can trust and reuse/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /correspondence/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/refresh-linked-threads', { jobApplicationId: 42 });
});
expect(await screen.findByText(/linked gmail thread continuity/i)).toBeInTheDocument();
expect(await screen.findByText(/without re-importing the whole thread/i)).toBeInTheDocument();
expect(await screen.findByText(/last linked refresh imported 1 new message/i)).toBeInTheDocument();
expect(await screen.findByText(/backend developer follow-up/i)).toBeInTheDocument();
expect(await screen.findByText(/following up on the role\./i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /follow up/i }));
expect(await screen.findByDisplayValue(/i wanted to follow up on the backend developer thread/i)).toBeInTheDocument();
expect(mockedApi.post.mock.calls.some(([url]) => url === '/jobapplications/42/send-followup')).toBe(false);
});
});