235 lines
9.7 KiB
TypeScript
235 lines
9.7 KiB
TypeScript
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]} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
|
<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);
|
|
});
|
|
});
|