feat(S05/T01): Unified workflow trust signals across the API, table, da…
- JobTrackerApi/Controllers/JobApplicationsController.cs - JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs - job-tracker-ui/src/jobWorkflowSignals.ts - job-tracker-ui/src/components/JobTable.tsx - job-tracker-ui/src/components/DashboardView.tsx - job-tracker-ui/src/components/RemindersView.tsx - job-tracker-ui/src/workflow-trust-signals.test.tsx
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
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 DashboardView from './components/DashboardView';
|
||||
import RemindersView from './components/RemindersView';
|
||||
import JobTable from './components/JobTable';
|
||||
import { api } from './api';
|
||||
import { JobApplication } from './types';
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function buildJob(overrides: Partial<JobApplication>): JobApplication {
|
||||
return {
|
||||
id: 1,
|
||||
jobTitle: 'Backend Developer',
|
||||
company: { id: 1, name: 'Acme' },
|
||||
companyId: 1,
|
||||
status: 'Waiting',
|
||||
dateApplied: new Date('2026-03-01T00:00:00Z').toISOString(),
|
||||
location: 'Oslo',
|
||||
salary: undefined,
|
||||
nextAction: undefined,
|
||||
followUpAt: new Date('2026-03-20T00:00:00Z').toISOString(),
|
||||
feedbackRequestedAt: undefined,
|
||||
responseReceived: false,
|
||||
responseDate: undefined,
|
||||
description: 'Role summary',
|
||||
translatedDescription: undefined,
|
||||
descriptionLanguage: undefined,
|
||||
tags: undefined,
|
||||
deadline: undefined,
|
||||
notes: 'General notes',
|
||||
coverLetterText: undefined,
|
||||
recruiterMessageDraft: undefined,
|
||||
jobUrl: undefined,
|
||||
shortSummary: 'Strong match',
|
||||
fullSummary: null,
|
||||
tailoredCvText: 'Saved CV',
|
||||
tailoredCvUpdatedAt: null,
|
||||
workflowSignal: {
|
||||
actionKey: 'follow-up',
|
||||
reason: 'Follow-up is due for this role.',
|
||||
workspaceTab: 'follow-up',
|
||||
followMode: 'waiting-update',
|
||||
needsAttention: true,
|
||||
hasPackageGap: false,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: true,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
hasResume: true,
|
||||
hasCoverLetter: false,
|
||||
hasPortfolio: false,
|
||||
hasOtherAttachment: false,
|
||||
daysSince: 10,
|
||||
isDeleted: false,
|
||||
deletedAt: undefined,
|
||||
needsFollowUp: true,
|
||||
followUpReason: 'Follow-up is due for this role.',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function setupApiMocks({ reminders, jobs }: { reminders?: JobApplication[]; jobs?: JobApplication[] }) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/companies') return Promise.resolve({ data: [{ id: 1, name: 'Acme' }, { id: 2, name: 'Beta' }] } as any);
|
||||
if (url === '/jobapplications/reminders') return Promise.resolve({ data: reminders ?? [] } as any);
|
||||
if (url === '/jobapplications') return Promise.resolve({ data: { items: jobs ?? [], total: jobs?.length ?? 0, page: 1, pageSize: 15 } } as any);
|
||||
if (url === '/jobapplications/stats') return Promise.resolve({ data: { total: reminders?.length ?? 0, active: reminders?.length ?? 0, deleted: 0, byStatus: {}, appliedLast30Days: reminders?.length ?? 0, averageDaysSinceApplied: 7 } } as any);
|
||||
if (url === '/jobapplications/analytics-overview') return Promise.resolve({ data: { funnel: [], responseRateBySource: [], topCompanies: [], totalResponses: 1, totalActive: reminders?.length ?? 0 } } as any);
|
||||
if (url === '/jobapplications/analytics' || url === '/jobapplications/tags') return Promise.resolve({ data: [] } as any);
|
||||
if (url === '/jobapplications/tag-trends') return Promise.resolve({ data: { months: [], series: [] } } as any);
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
}
|
||||
|
||||
function LocationIndicator() {
|
||||
const location = useLocation();
|
||||
return <div data-testid="location-indicator">{location.pathname}{location.search}</div>;
|
||||
}
|
||||
|
||||
function renderWithProviders(initialPath: string, routes: React.ReactNode) {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>{routes}</Routes>
|
||||
</MemoryRouter>
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('follow-up workflow signals route all overview surfaces to the same follow-up workspace', async () => {
|
||||
const job = buildJob({
|
||||
id: 42,
|
||||
followUpReason: 'Tailored CV missing',
|
||||
workflowSignal: {
|
||||
actionKey: 'follow-up',
|
||||
reason: 'Follow-up is due for this role.',
|
||||
workspaceTab: 'follow-up',
|
||||
followMode: 'waiting-update',
|
||||
needsAttention: true,
|
||||
hasPackageGap: false,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: true,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
});
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
|
||||
const dashboardRender = renderWithProviders('/dashboard', <>
|
||||
<Route path="/dashboard" element={<><LocationIndicator /><DashboardView /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
await screen.findByText(/follow-up is due for this role/i);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /follow up/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update'));
|
||||
dashboardRender.unmount();
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
const remindersRender = renderWithProviders('/reminders', <>
|
||||
<Route path="/reminders" element={<><LocationIndicator /><RemindersView /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /follow up/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update'));
|
||||
remindersRender.unmount();
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
renderWithProviders('/table', <>
|
||||
<Route path="/table" element={<><LocationIndicator /><JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /backend developer — follow up signal/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=42&tab=4&followMode=waiting-update'));
|
||||
});
|
||||
|
||||
test('package-work workflow signals route all overview surfaces to the shared tailored-cv workspace', async () => {
|
||||
const job = buildJob({
|
||||
id: 43,
|
||||
jobTitle: 'Platform Engineer',
|
||||
followUpReason: 'Follow-up due soon',
|
||||
workflowSignal: {
|
||||
actionKey: 'package-work',
|
||||
reason: 'Saved application answers still need work.',
|
||||
workspaceTab: 'tailored-cv',
|
||||
followMode: null,
|
||||
needsAttention: true,
|
||||
hasPackageGap: true,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: true,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: false,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
});
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
|
||||
const dashboardRender = renderWithProviders('/dashboard', <>
|
||||
<Route path="/dashboard" element={<><LocationIndicator /><DashboardView /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /build package/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3'));
|
||||
dashboardRender.unmount();
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
const remindersRender = renderWithProviders('/reminders', <>
|
||||
<Route path="/reminders" element={<><LocationIndicator /><RemindersView /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /build package/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3'));
|
||||
remindersRender.unmount();
|
||||
|
||||
setupApiMocks({ reminders: [job], jobs: [job] });
|
||||
renderWithProviders('/table', <>
|
||||
<Route path="/table" element={<><LocationIndicator /><JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" /></>} />
|
||||
<Route path="/jobs" element={<LocationIndicator />} />
|
||||
</>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /platform engineer — build package signal/i }));
|
||||
await waitFor(() => expect(screen.getByTestId('location-indicator')).toHaveTextContent('/jobs?open=43&tab=3'));
|
||||
});
|
||||
|
||||
test('job table readiness filter follows workflow signals instead of raw notes or cv text heuristics', async () => {
|
||||
const packageGapJob = buildJob({
|
||||
id: 44,
|
||||
jobTitle: 'Application Engineer',
|
||||
status: 'Applied',
|
||||
notes: 'General notes only',
|
||||
tailoredCvText: 'Saved tailored CV',
|
||||
workflowSignal: {
|
||||
actionKey: 'package-work',
|
||||
reason: 'Saved application answers still need work.',
|
||||
workspaceTab: 'tailored-cv',
|
||||
followMode: null,
|
||||
needsAttention: true,
|
||||
hasPackageGap: true,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: false,
|
||||
hasTailoredCv: true,
|
||||
hasSavedApplicationAnswerDraft: false,
|
||||
hasInterviewPrepNotes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const readyRejectedJob = buildJob({
|
||||
id: 45,
|
||||
jobTitle: 'Operations Analyst',
|
||||
status: 'Rejected',
|
||||
notes: 'Some notes',
|
||||
tailoredCvText: null,
|
||||
needsFollowUp: false,
|
||||
followUpReason: null,
|
||||
workflowSignal: {
|
||||
actionKey: 'review-readiness',
|
||||
reason: 'No urgent workflow gaps are blocking this job right now.',
|
||||
workspaceTab: 'readiness',
|
||||
followMode: null,
|
||||
needsAttention: false,
|
||||
hasPackageGap: false,
|
||||
needsInterviewPrep: false,
|
||||
needsFollowUpAction: false,
|
||||
hasTailoredCv: false,
|
||||
hasSavedApplicationAnswerDraft: false,
|
||||
hasInterviewPrepNotes: false,
|
||||
},
|
||||
});
|
||||
|
||||
setupApiMocks({ jobs: [packageGapJob, readyRejectedJob] });
|
||||
|
||||
renderWithProviders('/table', <>
|
||||
<Route path="/table" element={<JobTable refreshToken={0} pageSize={15} onPageSizeChange={() => {}} columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }} onColumnsChange={() => {}} mode="jobs" />} />
|
||||
</>);
|
||||
|
||||
const readinessSelect = (await screen.findAllByRole('combobox')).find((element) => /all readiness/i.test(element.textContent ?? ''));
|
||||
expect(readinessSelect).toBeTruthy();
|
||||
fireEvent.mouseDown(readinessSelect as HTMLElement);
|
||||
fireEvent.click(await screen.findByRole('option', { name: /needs work/i }));
|
||||
|
||||
expect(await screen.findByText(/application engineer/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/operations analyst/i)).not.toBeInTheDocument();
|
||||
});
|
||||
Reference in New Issue
Block a user