mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot Add an Applications module at /dashboard/applications: pipeline board (dnd-kit), table view with bulk actions, Insights (fit tiles, funnel, sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and Add/Edit/Detail slide-overs. Each application links a live Reactive Resume. AI "Application Copilot" (applications.ai.*): job-posting autofill, resume↔job match score (fit ring), resume tailoring, and cover-letter / follow-up drafting — via the user's configured provider. Board cards + table rows get context menus (edit / move / archive / delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox. Also includes local TanStack devtools setup and toolchain bumps. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * feat(applications): close follow-up gaps + squash migrations Finish the deferred/open items on the applications tracker: - Cover-letter upload re-enabled. Fix the storage blocker by deriving the key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE) instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add coverLetterUrl/coverLetterName columns + Documents-section upload/remove. - Contacts editor in the detail sheet (add/edit/remove, keyed per app). - Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more"). - Extract new Lingui messages across locales. - Guard coverLetterUrl to http(s)/relative at the API boundary. Squash the five branch-only application-table migrations (create -> +tags -> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via drizzle-kit generate. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * chore: update dependencies * fix(web): address React Doctor findings — compiler, purity, query, component structure prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces, handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to module scope so they aren't rebuilt on every render. react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the non-assignment form to unblock auto-memoization. set-state-in-effect: derive updatedAtLabel at render time instead of syncing it through useState + useEffect. query-destructure-result: destructure useQuery results at call site in resume-analysis and resume-thumbnail to follow TanStack Query v5 convention. only-export-components: extract non-component exports to sibling .ts files so Fast Refresh can preserve component state: - getNextWeights → typography/get-next-weights.ts - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts - getLocaleOptions → features/locale/locale-options.tsx - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts - computeDelta + getSparklinePoints → statistics.utils.ts no-multi-comp: split multi-component files into focused companions: - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts fix(tests): add Resume type import to section-builder mocks and cast partial mock data as unknown as Resume to satisfy stricter type checking; fix noExplicitAny Biome errors in the same mocks. * feat(applications): improve performance * chore: fix knip issues * perf(builder): halve per-keystroke render cost Section-form fields called `form.handleSubmit()` on every keystroke, which re-validated the whole form and toggled submit state — firing the render cascade twice per character (~6809 renders/keystroke, FPS dropping to 9). Persist via a form-level `listeners.onChange` instead and drop the per-field `handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume subscriptions to metadata slices so they no longer re-render on content edits. Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview, and design controls verified working; 449/449 web tests pass. * perf(home): eliminate hero CLS from unreserved video box The hero <section> is `flex items-center` (shrink-to-fit), so the video wrapper's width depended on the video's intrinsic size, which only resolves after the media loads. aspect-ratio couldn't reserve height without a definite width, so the video grew from ~190px to ~563px after first paint and shoved the centered hero text down ~373px (CLS ~0.095). Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an explicit aspect ratio + width/height on the video so its box is reserved before load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl. * docs: add application tracker guides * chore(db): squash application migrations * fix(email): import React in auth template for server-side rendering compatibility * chore(release): v5.2.1 * Refactor resume rendering and builder workflows * fix: address application tracker review findings
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const lookupMock = vi.hoisted(() => vi.fn());
|
||||
const requestMock = vi.hoisted(() => vi.fn());
|
||||
const protectedProcedureMock = vi.hoisted(() => {
|
||||
const chain = {
|
||||
route: vi.fn(() => chain),
|
||||
input: vi.fn(() => chain),
|
||||
use: vi.fn(() => chain),
|
||||
output: vi.fn(() => chain),
|
||||
handler: vi.fn(() => chain),
|
||||
};
|
||||
return chain;
|
||||
});
|
||||
|
||||
vi.mock("node:dns/promises", () => ({ lookup: lookupMock }));
|
||||
vi.mock("node:http", () => ({ request: requestMock }));
|
||||
vi.mock("node:https", () => ({ request: requestMock }));
|
||||
vi.mock("ai", () => ({ generateText: vi.fn() }));
|
||||
vi.mock("../../context", () => ({ protectedProcedure: protectedProcedureMock }));
|
||||
vi.mock("../../middleware/rate-limit", () => ({ aiRequestRateLimit: vi.fn() }));
|
||||
vi.mock("../ai/service", () => ({ getModel: vi.fn() }));
|
||||
vi.mock("../ai-providers/service", () => ({ aiProvidersService: { getDefaultRunnable: vi.fn() } }));
|
||||
vi.mock("../resume/service", () => ({ resumeService: { getById: vi.fn(), create: vi.fn() } }));
|
||||
vi.mock("./service", () => ({
|
||||
applicationService: { getById: vi.fn(), setAiResult: vi.fn(), update: vi.fn(), addNote: vi.fn() },
|
||||
}));
|
||||
|
||||
const { autofillInputSchema, fetchJobPostingText } = await import("./ai");
|
||||
|
||||
function mockRequestResponse(statusCode: number, headers: Record<string, string>, body = "") {
|
||||
requestMock.mockImplementation((_url, _options, callback) => {
|
||||
const response = Readable.from(body ? [Buffer.from(body)] : []) as Readable & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
response.statusCode = statusCode;
|
||||
response.headers = headers;
|
||||
callback(response);
|
||||
return { on: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
}
|
||||
|
||||
describe("fetchJobPostingText", () => {
|
||||
beforeEach(() => {
|
||||
lookupMock.mockReset();
|
||||
requestMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("rejects private IP URLs before fetching", async () => {
|
||||
await expect(fetchJobPostingText("http://127.0.0.1/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects hostnames that resolve to private addresses", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "169.254.169.254", family: 4 }]);
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("converts DNS lookup failures to bad requests", async () => {
|
||||
lookupMock.mockRejectedValue(new Error("ENOTFOUND"));
|
||||
|
||||
await expect(fetchJobPostingText("https://missing.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects redirects instead of following them", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
mockRequestResponse(302, { location: "/" });
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejects oversized pages before reading the body", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
mockRequestResponse(200, { "content-length": "200001", "content-type": "text/html" }, "ignored");
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("pins the request lookup to the validated public address", async () => {
|
||||
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
|
||||
let pinnedAddress: string | undefined;
|
||||
requestMock.mockImplementation((_url, options, callback) => {
|
||||
options.lookup("jobs.example", {}, (_error: Error | null, address: string) => {
|
||||
pinnedAddress = address;
|
||||
});
|
||||
const response = Readable.from([
|
||||
Buffer.from("<html><script>nope</script><body><h1>Senior Engineer</h1></body></html>"),
|
||||
]) as Readable & {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
response.statusCode = 200;
|
||||
response.headers = { "content-type": "text/html" };
|
||||
callback(response);
|
||||
return { on: vi.fn(), end: vi.fn() };
|
||||
});
|
||||
|
||||
await expect(fetchJobPostingText("https://jobs.example/posting")).resolves.toBe("Senior Engineer");
|
||||
expect(pinnedAddress).toBe("93.184.216.34");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autofillInputSchema", () => {
|
||||
it("rejects oversized pasted job descriptions", () => {
|
||||
expect(() => autofillInputSchema.parse({ jobDescription: "x".repeat(20_001) })).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user