feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history
Unblocks the documented core workflow and closes the AI-service exposure, without changing existing behaviour. Job/JobApplication split (additive; see ADR-002): - New Job entity (the opportunity) with owner-scoped query filter; nullable JobApplication.JobId FK. Nothing reads Job yet. - Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned tables the scaffolder re-emitted; verified against the real dev DB. Pipeline: 10 internal stages across three concerns kept separate — PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) / PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn; keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage chip and full transitions; drag applies only safe transitions (never infers Ghosted/Withdrawn). DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay accurate; the discarded date is preserved as an AppliedDateCleared JobEvent. AI service lockdown: no host port; private ai_internal network (backend is the only other member); X-Ai-Service-Token required on all non-/health endpoints; AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live stack. Also carries two pre-existing working-tree files (views/ProfilePage.tsx, views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration. Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
import { JobApplication } from './types';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import KanbanBoard from './components/KanbanBoard';
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function job(id: number, jobTitle: string, status: string, overrides: Partial<JobApplication> = {}): JobApplication {
|
||||
return {
|
||||
id,
|
||||
jobTitle,
|
||||
company: { id: 1, name: 'Acme' },
|
||||
companyId: 1,
|
||||
status,
|
||||
// Pre-application stages carry no applied date — that is the whole point of the split.
|
||||
dateApplied: null,
|
||||
savedAt: new Date('2026-07-01T00:00:00Z').toISOString(),
|
||||
daysSince: null,
|
||||
...overrides,
|
||||
} as JobApplication;
|
||||
}
|
||||
|
||||
function renderBoard() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<KanbanBoard />
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('the board collapses ten stages into the three agreed groups', async () => {
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: [
|
||||
job(1, 'Saved Role', 'Saved'),
|
||||
job(2, 'Preparing Role', 'Preparing'),
|
||||
job(3, 'Applied Role', 'Applied', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
|
||||
job(4, 'Offer Role', 'Offer', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
|
||||
job(5, 'Withdrawn Role', 'Withdrawn', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
|
||||
],
|
||||
} as any);
|
||||
|
||||
renderBoard();
|
||||
|
||||
await screen.findByText('Not Applied');
|
||||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Closed')).toBeInTheDocument();
|
||||
|
||||
// Only the three groups — no "Other" column, because no custom statuses are present.
|
||||
expect(screen.queryByText('Other')).not.toBeInTheDocument();
|
||||
|
||||
// Every job is placed, and grouping does not hide which stage it is actually in.
|
||||
expect(screen.getByText('Saved Role')).toBeInTheDocument();
|
||||
expect(screen.getByText('Withdrawn Role')).toBeInTheDocument();
|
||||
expect(screen.getByText('Withdrawn')).toBeInTheDocument();
|
||||
expect(screen.getByText('Preparing')).toBeInTheDocument();
|
||||
// Offer groups under Active even though it is a Success category on the backend.
|
||||
expect(screen.getByText('Offer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a custom status gets an Other column that is not a drop target', async () => {
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: [job(1, 'Odd Role', 'Take-home assignment')],
|
||||
} as any);
|
||||
|
||||
renderBoard();
|
||||
|
||||
await screen.findByText('Other');
|
||||
// Custom statuses survive rather than being coerced into a canonical stage.
|
||||
expect(screen.getByText('Take-home assignment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('dropping onto a group applies that group entry stage', async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [job(7, 'Saved Role', 'Saved')] } as any);
|
||||
|
||||
renderBoard();
|
||||
|
||||
const card = await screen.findByText('Saved Role');
|
||||
const column = screen.getByText('Active').closest('div')!.parentElement!.parentElement!;
|
||||
|
||||
fireEvent.dragStart(card.closest('.MuiCard-root')!);
|
||||
fireEvent.dragOver(column);
|
||||
fireEvent.drop(column);
|
||||
|
||||
// A group is not a status, so a coarse drag applies the group's entry stage.
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/7/status', { status: 'Applied' }));
|
||||
});
|
||||
|
||||
test('the card menu offers precise stages grouped by section', async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [job(9, 'Saved Role', 'Saved')] } as any);
|
||||
|
||||
renderBoard();
|
||||
|
||||
await screen.findByText('Saved Role');
|
||||
fireEvent.click(screen.getByRole('button', { name: '' }) ?? screen.getAllByRole('button')[0]);
|
||||
|
||||
const menu = await screen.findByRole('menu');
|
||||
// Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all.
|
||||
expect(within(menu).getByText(/Ghosted/)).toBeInTheDocument();
|
||||
expect(within(menu).getByText(/Withdrawn/)).toBeInTheDocument();
|
||||
// The job's own current stage is not offered as a target.
|
||||
expect(within(menu).queryByText(/Set status: Saved/)).not.toBeInTheDocument();
|
||||
});
|
||||
Reference in New Issue
Block a user