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:
@@ -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}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user