>>',
+ 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);
+ });
+});