From a0d1c1c05b03195bfe43371b5859b6f27a6ad7c6 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 24 Mar 2026 14:36:46 +0100 Subject: [PATCH] =?UTF-8?q?feat(S05/T02):=20Added=20an=20end-to-end=20trus?= =?UTF-8?q?t-loop=20regression,=20surfaced=20save=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gsd/journal/2026-03-24.jsonl | 4 + .gsd/milestones/M001/slices/S05/S05-PLAN.md | 2 +- .gsd/milestones/M001/slices/S05/S05-UAT.md | 90 +++++++ .../M001/slices/S05/tasks/T02-SUMMARY.md | 77 ++++++ .../src/components/Correspondence.tsx | 22 ++ .../src/components/JobDetailsDialog.tsx | 9 +- .../src/end-to-end-trust-loop.test.tsx | 234 ++++++++++++++++++ 7 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 .gsd/milestones/M001/slices/S05/S05-UAT.md create mode 100644 .gsd/milestones/M001/slices/S05/tasks/T02-SUMMARY.md create mode 100644 job-tracker-ui/src/end-to-end-trust-loop.test.tsx diff --git a/.gsd/journal/2026-03-24.jsonl b/.gsd/journal/2026-03-24.jsonl index b9c204e..9f3a260 100644 --- a/.gsd/journal/2026-03-24.jsonl +++ b/.gsd/journal/2026-03-24.jsonl @@ -81,3 +81,7 @@ {"ts":"2026-03-24T13:28:02.081Z","flowId":"f43a3440-e3a0-4318-9d04-bea182f79112","seq":2,"eventType":"dispatch-match","rule":"executing → execute-task","data":{"unitType":"execute-task","unitId":"M001/S05/T02"}} {"ts":"2026-03-24T13:28:02.089Z","flowId":"f43a3440-e3a0-4318-9d04-bea182f79112","seq":3,"eventType":"unit-start","data":{"unitType":"execute-task","unitId":"M001/S05/T02"}} {"ts":"2026-03-24T13:28:41.212Z","flowId":"f43a3440-e3a0-4318-9d04-bea182f79112","seq":4,"eventType":"unit-end","data":{"unitType":"execute-task","unitId":"M001/S05/T02","status":"completed","artifactVerified":false},"causedBy":{"flowId":"f43a3440-e3a0-4318-9d04-bea182f79112","seq":3}} +{"ts":"2026-03-24T13:28:41.617Z","flowId":"e3a17c2a-1e81-4cd3-982a-05e166ddb465","seq":1,"eventType":"iteration-start","data":{"iteration":3}} +{"ts":"2026-03-24T13:28:41.701Z","flowId":"e3a17c2a-1e81-4cd3-982a-05e166ddb465","seq":2,"eventType":"dispatch-match","rule":"executing → execute-task","data":{"unitType":"execute-task","unitId":"M001/S05/T02"}} +{"ts":"2026-03-24T13:28:41.708Z","flowId":"e3a17c2a-1e81-4cd3-982a-05e166ddb465","seq":3,"eventType":"unit-start","data":{"unitType":"execute-task","unitId":"M001/S05/T02"}} +{"ts":"2026-03-24T13:36:46.139Z","flowId":"e3a17c2a-1e81-4cd3-982a-05e166ddb465","seq":4,"eventType":"unit-end","data":{"unitType":"execute-task","unitId":"M001/S05/T02","status":"completed","artifactVerified":true},"causedBy":{"flowId":"e3a17c2a-1e81-4cd3-982a-05e166ddb465","seq":3}} diff --git a/.gsd/milestones/M001/slices/S05/S05-PLAN.md b/.gsd/milestones/M001/slices/S05/S05-PLAN.md index 0ef7818..67e913e 100644 --- a/.gsd/milestones/M001/slices/S05/S05-PLAN.md +++ b/.gsd/milestones/M001/slices/S05/S05-PLAN.md @@ -50,7 +50,7 @@ The work is grouped into two tasks because the slice has two different risks. Fi - Do: add explicit workflow trust/action fields or normalized routing metadata at the controller/DTO layer, introduce a shared UI helper that consumes those fields instead of parsing free-form strings or raw `notes`, and update table/dashboard/reminders to route from the same source of truth without breaking the existing shared `/jobs?open=...&tab=...` workspace entry pattern. - Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsWorkflowSignalsTests` and `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/workflow-trust-signals.test.tsx` - Done when: the overview surfaces and readiness logic describe the same next action for the same job without depending on ad-hoc string matching or treating all `notes` content as generic package readiness. -- [ ] **T02: Add integrated trust-loop proof and workspace polish** `est:5h` +- [x] **T02: Add integrated trust-loop proof and workspace polish** `est:5h` - Why: the milestone still needs one trustworthy proof path that composes the existing package, Gmail, and follow-up slices and exposes any remaining trust gaps in the real workspace. - Files: `job-tracker-ui/src/components/JobDetailsDialog.tsx`, `job-tracker-ui/src/components/Correspondence.tsx`, `job-tracker-ui/src/end-to-end-trust-loop.test.tsx`, `job-tracker-ui/src/daily-control-loop.test.tsx`, `.gsd/milestones/M001/slices/S05/S05-UAT.md` - Do: add a focused integrated UI regression that starts from an overview entry surface and walks through saved package reuse, linked-thread continuity, and grounded follow-up drafting; patch only the workspace/continuity UI needed to make that path trustworthy and explicit; and capture a live-safe UAT script that preserves the manual-send boundary. diff --git a/.gsd/milestones/M001/slices/S05/S05-UAT.md b/.gsd/milestones/M001/slices/S05/S05-UAT.md new file mode 100644 index 0000000..68c6339 --- /dev/null +++ b/.gsd/milestones/M001/slices/S05/S05-UAT.md @@ -0,0 +1,90 @@ +# S05 Live-Safe UAT: Trust Loop Verification + +## Goal + +Verify the end-to-end trust loop on one real job without accidentally sending recruiter email. + +## Safety Guardrails + +1. **Do not press `Send and log email`** unless the environment is explicitly configured to a safe sink, stub mailbox, or other non-production outbound target. +2. If you are not certain the outbound email path is safe, stop after reviewing the generated follow-up draft. +3. Do not paste private recruiter or correspondence bodies into screenshots, tickets, or logs. +4. Prefer a job that already has: + - saved package material on the job, + - at least one linked Gmail thread, + - a follow-up reason/readiness signal. + +## Preconditions + +- The API and UI are running against the intended environment. +- Gmail integration is authenticated for the current user. +- The selected job has an identifiable company, job title, and recruiter mailbox/thread history. +- If you plan to verify the send step, outbound email must point at a safe sink/stub. Otherwise stop before sending. + +## Shared Expected Trust Signals + +The same job should present one coherent next action across all entry points: + +- `/jobs` +- `/dashboard` +- `/reminders` + +The workspace should make these states clear: + +- saved package material can be reused, +- linked Gmail refresh checks already-linked threads instead of requiring full re-import, +- follow-up draft generation is grounded in saved package + correspondence context, +- email sending remains manual. + +## Flow A — Start from `/jobs` + +1. Open `/jobs`. +2. Find a job with a trust/workflow signal chip or next-action button. +3. Open the job via the primary next-action control. +4. Confirm the workspace opens on the expected tab for that action. +5. In **Tailored CV**: + - verify the saved tailored CV, cover letter, application answer, and recruiter message are present if the job previously had them, + - confirm the workspace explicitly says the saved package material feeds follow-up drafting. +6. In **Correspondence**: + - confirm the linked-thread continuity panel is visible, + - confirm Gmail connection state is shown, + - confirm linked thread count is shown when applicable, + - trigger **Refresh linked threads** if manual confirmation is needed, + - verify new linked correspondence appears without importing the full thread again. +7. In **Follow up**: + - confirm the draft context references saved package material and thread context, + - confirm the manual-send boundary copy is visible, + - review the generated draft but **stop before `Send and log email`** unless the environment is explicitly safe. + +## Flow B — Start from `/dashboard` + +1. Open `/dashboard`. +2. Find the same job in an attention/reminder card. +3. Open it from the dashboard action. +4. Confirm it lands in the same job workspace and the same trust state is visible: + - package material is still saved, + - correspondence continuity is preserved, + - follow-up context remains grounded. + +## Flow C — Start from `/reminders` + +1. Open `/reminders`. +2. Find the same job in the appropriate reminder grouping. +3. Open it from the reminder action. +4. Confirm it lands in the same workspace for the same job and preserves the same action semantics. + +## Pass Criteria + +- The same job can be opened from `/jobs`, `/dashboard`, and `/reminders`. +- The saved application package is visible and clearly reusable for follow-up drafting. +- Linked Gmail refresh shows continuity on already-linked threads without requiring a whole-thread re-import workflow. +- The follow-up draft is grounded in saved package and correspondence context. +- No recruiter email is sent unless the human explicitly chooses `Send and log email` in a safe environment. + +## Failure Clues + +- Entry points route to different tabs or imply different next actions for the same job. +- Saved package material is missing or no longer called out as reusable context. +- Linked-thread refresh state is invisible or requires re-importing a thread that is already linked. +- Follow-up drafting triggers outbound send behavior or makes send/regenerate coupling unclear. +- Workspace state differs depending on whether the job was opened from jobs, dashboard, or reminders. diff --git a/.gsd/milestones/M001/slices/S05/tasks/T02-SUMMARY.md b/.gsd/milestones/M001/slices/S05/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..b7c2811 --- /dev/null +++ b/.gsd/milestones/M001/slices/S05/tasks/T02-SUMMARY.md @@ -0,0 +1,77 @@ +--- +id: T02 +parent: S05 +milestone: M001 +provides: + - Integrated trust-loop regression covering overview entry, saved package reuse, linked Gmail continuity, and grounded follow-up drafting +key_files: + - 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 +key_decisions: + - Exposed linked-thread refresh status directly in the correspondence workspace instead of hiding continuity feedback inside the Gmail import modal. +patterns_established: + - Integrated workflow proof should open the real job workspace from an overview action, then verify package reuse, correspondence continuity, and follow-up draft/manual-send separation in one test. +observability_surfaces: + - 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 + - GET /api/jobapplications/{id}/followup-draft + - GET /api/correspondence/{jobId} + - POST /api/gmail/refresh-linked-threads +duration: 33m +verification_result: passed +completed_at: 2026-03-24T14:35:40+01:00 +blocker_discovered: false +--- + +# T02: Add integrated trust-loop proof and workspace polish + +**Added an end-to-end trust-loop regression, surfaced saved-package and Gmail continuity trust state in the workspace, and documented a live-safe UAT runbook.** + +## What Happened + +I verified the existing S01-S04 tests and workspace code first, then added a new focused React regression at `job-tracker-ui/src/end-to-end-trust-loop.test.tsx` that starts from the real `/jobs` overview action path and proves the composed loop in one place: open the workspace from the overview, confirm saved package material is already present, confirm linked Gmail thread refresh brings in new correspondence without re-importing the thread, and confirm the grounded follow-up draft remains available without calling `send-followup`. + +To make that integrated path trustworthy in the actual UI, I limited code changes to the two planned components. In `JobDetailsDialog.tsx`, I tightened the package-reuse copy and added an explicit manual-send boundary panel in the follow-up workspace so draft generation/regeneration cannot be mistaken for sending. In `Correspondence.tsx`, I added a linked-thread continuity panel to the main workspace so Gmail connection state, linked-thread count, and last refresh outcome are visible without opening the import modal. + +I also wrote `.gsd/milestones/M001/slices/S05/S05-UAT.md` as the live-safe runbook for the final human loop. It tells a human how to verify `/jobs`, `/dashboard`, and `/reminders` against real services while stopping before `Send and log email` unless outbound mail is explicitly pointed at a safe sink/stub. + +## Verification + +I ran the full slice command suite: the backend workflow-signal tests, the focused workflow-signal React suite, the new integrated trust-loop regression, the existing Gmail/package/follow-up/daily-loop bundle, and the production build. All command-based verification passed. + +I also attempted a real browser sanity pass by starting the local UI and navigating to `http://localhost:3000/jobs`. The browser reached the app shell, but the live verification could not proceed because an already-running process on port `5202` responded without the expected CORS headers, so the UI could not load API data. I did not treat that as a slice blocker because it is an environment/runtime collision outside the T02 code changes, and the live-safe UAT runbook is now ready for a correctly configured environment. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter JobApplicationsWorkflowSignalsTests` | 0 | ✅ pass | 4.79s | +| 2 | `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/workflow-trust-signals.test.tsx` | 0 | ✅ pass | 3.85s | +| 3 | `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/end-to-end-trust-loop.test.tsx` | 0 | ✅ pass | 3.58s | +| 4 | `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx src/job-details-generated-drafts.test.tsx src/job-details-followup-drafts.test.tsx src/daily-control-loop.test.tsx` | 0 | ✅ pass | 6.54s | +| 5 | `CI=true npm --prefix job-tracker-ui run build` | 0 | ✅ pass | 15.55s | + +## Diagnostics + +Use `job-tracker-ui/src/end-to-end-trust-loop.test.tsx` as the single integrated proof for the slice. Inspect `job-tracker-ui/src/components/JobDetailsDialog.tsx` for the saved-package reuse and manual-send boundary copy, and inspect `job-tracker-ui/src/components/Correspondence.tsx` for linked-thread continuity state. For live verification, follow `.gsd/milestones/M001/slices/S05/S05-UAT.md`. If the real browser flow fails, inspect `GET /api/jobapplications/{id}/followup-draft`, `GET /api/correspondence/{jobId}`, and `POST /api/gmail/refresh-linked-threads` together with browser network/CORS diagnostics. + +## Deviations + +I did not need to update `job-tracker-ui/src/daily-control-loop.test.tsx` because the shared entry semantics from T01 still held once the integrated regression was added. + +## Known Issues + +- A live browser sanity attempt against `http://localhost:3000/jobs` was blocked by a pre-existing process bound to port `5202` that did not return the expected CORS headers for `http://localhost:3000`, so the app shell loaded but API-backed job data did not. The code changes in this task were still verified through the full automated command suite. +- React Router future-flag warnings still appear during test runs, but they are warnings only and did not affect pass/fail outcomes. + +## Files Created/Modified + +- `job-tracker-ui/src/end-to-end-trust-loop.test.tsx` — added the integrated overview → workspace → package reuse → Gmail continuity → follow-up/manual-send regression. +- `job-tracker-ui/src/components/JobDetailsDialog.tsx` — clarified saved-package reuse and added explicit manual-send boundary copy in the follow-up workspace. +- `job-tracker-ui/src/components/Correspondence.tsx` — surfaced linked-thread continuity and last-refresh status directly in the main correspondence workspace. +- `.gsd/milestones/M001/slices/S05/S05-UAT.md` — documented the live-safe final human verification flow and send-safety guardrails. +- `.gsd/milestones/M001/slices/S05/S05-PLAN.md` — marked T02 complete. diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index 2ec966d..25302a8 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -405,6 +405,28 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job )} + + Linked Gmail thread continuity + + 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. + + + + 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} /> + {linkedThreadRefresh ? ( + 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} + + + v && setFrom(v)} size="small"> {t("correspondenceMe")} diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 75d7df8..6850e32 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -553,6 +553,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, + {packageGeneratedAt ? : null} {t("jobDetailsTailoredCvIntro")} @@ -584,7 +585,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, /> Saved working material - These saved copies are what later slices can trust and reuse. + These saved copies are what follow-up drafting and later slices can trust and reuse. Tailored CV: {(job?.tailoredCvText ?? "").trim() ? "Saved on this job" : "Not saved yet"} Cover letter: {savedPackageWorkspace.coverLetter.trim() ? "Saved on this job" : "Not saved yet"} @@ -632,6 +633,12 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, + + Manual send boundary + + Generating or regenerating this grounded draft never sends recruiter email. The only outbound step is the explicit “Send and log email” action below. + + setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} Manual send only — nothing is dispatched until you press send.`} /> setDraftSubject(e.target.value)} /> setDraftBody(e.target.value)} helperText="You can edit this before sending. Sending stays manual and logs the sent note back to correspondence." /> diff --git a/job-tracker-ui/src/end-to-end-trust-loop.test.tsx b/job-tracker-ui/src/end-to-end-trust-loop.test.tsx new file mode 100644 index 0000000..0193096 --- /dev/null +++ b/job-tracker-ui/src/end-to-end-trust-loop.test.tsx @@ -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; + +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<<>>\nSaved application answer\n<<>>', + 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
{location.pathname}{location.search}
; +} + +function renderLoop(initialPath = '/jobs') { + return render( + + + + + + + + {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} /> + + + + + + , + ); +} + +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 ', + 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 , user@example.test'], + threadSubject: 'Backend Developer application update', + lastCorrespondenceFrom: 'Maria Recruiter ', + 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 ', + }, + ]; + } + + 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); + }); +});