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); }); });