Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions __tests__/defaultResume.actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,5 +271,52 @@ describe("Default Resume Actions", () => {
}),
);
});

it("includes file-backed resumes when requested for automation matching", async () => {
(prisma.user.findUnique as any).mockResolvedValue({
defaultResumeId: null,
});
(prisma.resume.findMany as any).mockResolvedValue([
{
id: "structured-resume",
FileId: null,
_count: { Job: 0, ResumeSections: 2 },
},
{
id: "file-backed-resume",
FileId: "file-1",
_count: { Job: 0, ResumeSections: 0 },
},
{
id: "empty-resume",
FileId: null,
_count: { Job: 0, ResumeSections: 1 },
},
]);

const result = await getResumeList(1, 100, 2, true);

expect(result.data.map((r: any) => r.id)).toEqual([
"structured-resume",
"file-backed-resume",
]);
});

it("keeps file-backed resumes excluded by the default structured-only filter", async () => {
(prisma.user.findUnique as any).mockResolvedValue({
defaultResumeId: null,
});
(prisma.resume.findMany as any).mockResolvedValue([
{
id: "file-backed-resume",
FileId: "file-1",
_count: { Job: 0, ResumeSections: 0 },
},
]);

const result = await getResumeList(1, 100, 2);

expect(result.data).toEqual([]);
});
});
});
32 changes: 32 additions & 0 deletions __tests__/greenhouse-runner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,17 @@ vi.mock("@/lib/ai", async (orig) => {
return { ...actual, getModel: vi.fn().mockResolvedValue({}) };
});

vi.mock("@/lib/ai/import/read-resume-file", () => ({
extractAttachedResumeText: vi.fn(),
}));

import { runAutomation } from "@/lib/scraper/runner";
import { searchGreenhouseJobs } from "@/lib/scraper/greenhouse";
import { searchJSearchJobs } from "@/lib/scraper/jsearch";
import { generateText } from "ai";
import type { Automation } from "@/models/automation.model";
import { AiProvider } from "@/models/ai.model";
import { extractAttachedResumeText } from "@/lib/ai/import/read-resume-file";

// Each call to generateText returns a promise you resolve/reject manually,
// in whatever order the test wants — lets you simulate out-of-order
Expand Down Expand Up @@ -202,6 +207,33 @@ describe("runAutomation (greenhouse)", () => {
expect((generateText as any).mock.calls.length).toBe(0);
});

it("uses attached resume text when structured sections are absent", async () => {
(prisma.resume.findUnique as any).mockResolvedValueOnce({
id: "resume1",
title: "Uploaded Resume",
File: { filePath: "/data/files/resumes/uploaded.pdf" },
ContactInfo: null,
ResumeSections: [],
});
(extractAttachedResumeText as any).mockResolvedValue(
"Senior frontend engineer with React and TypeScript experience.",
);
(searchGreenhouseJobs as any).mockResolvedValue({
jobs: [makeJob("Frontend Engineer", "React")],
errors: [],
});

const result = await runAutomation(automation);

expect(result.status).toBe("completed");
expect(extractAttachedResumeText).toHaveBeenCalledWith(
"/data/files/resumes/uploaded.pdf",
);
expect((generateText as any).mock.calls[0][0].prompt).toContain(
"Senior frontend engineer with React and TypeScript experience.",
);
});

// Exercises getExistingJobDedupeMap's wiring of real DB row shape (JobTitle/
// Company/Location relations) into jobDedupeKey, not just the pure
// dedupeJobs unit tested in scraper-utils.spec.ts.
Expand Down
59 changes: 59 additions & 0 deletions __tests__/read-resume-file.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import path from "path";
import { extractAttachedResumeText } from "@/lib/ai/import/read-resume-file";
import { APP_CONSTANTS } from "@/lib/constants";

const mockReadFile = vi.hoisted(() => vi.fn());
const mockExtractText = vi.hoisted(() => vi.fn());

vi.mock("fs/promises", () => ({
readFile: mockReadFile,
default: { readFile: mockReadFile },
}));

vi.mock("@/lib/ai/import/extract-text", () => ({
extractText: mockExtractText,
}));

describe("extractAttachedResumeText", () => {
const filePath = path.join(
APP_CONSTANTS.UPLOADS_DIR,
"files",
"resumes",
"resume.pdf",
);

beforeEach(() => {
vi.clearAllMocks();
});

it("extracts text from a file inside the uploads directory", async () => {
mockReadFile.mockResolvedValue(Buffer.from("pdf"));
mockExtractText.mockResolvedValue({
success: true,
data: { text: "Senior engineer", truncated: false },
});

await expect(extractAttachedResumeText(filePath)).resolves.toBe(
"Senior engineer",
);
expect(mockReadFile).toHaveBeenCalledWith(path.resolve(filePath));
expect(mockExtractText).toHaveBeenCalledWith(Buffer.from("pdf"));
});

it("does not read paths outside the uploads directory", async () => {
await expect(
extractAttachedResumeText("/tmp/not-a-resume.pdf"),
).resolves.toBeNull();
expect(mockReadFile).not.toHaveBeenCalled();
});

it("returns null when extraction fails", async () => {
mockReadFile.mockResolvedValue(Buffer.from("pdf"));
mockExtractText.mockResolvedValue({
success: false,
error: { code: "NO_TEXT", message: "No text" },
});

await expect(extractAttachedResumeText(filePath)).resolves.toBeNull();
});
});
10 changes: 9 additions & 1 deletion src/actions/profile/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export const getResumeList = async (
page: number = 1,
limit: number = APP_CONSTANTS.RECORDS_PER_PAGE,
minSections: number = 0,
// File-backed resumes can be matched by consumers that extract their file
// text (currently automation); other consumers keep the structured-only
// behavior by default.
includeFileBacked: boolean = false,
): Promise<any | undefined> => {
try {
const user = await requireUser();
Expand Down Expand Up @@ -82,7 +86,11 @@ export const getResumeList = async (

const data =
minSections > 0
? rawData.filter((r) => r._count.ResumeSections >= minSections)
? rawData.filter(
(r) =>
r._count.ResumeSections >= minSections ||
(includeFileBacked && Boolean(r.FileId)),
)
: rawData;

return { data, total, success: true };
Expand Down
27 changes: 15 additions & 12 deletions src/app/dashboard/automations/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,18 +222,21 @@ export default function AutomationDetailPage() {
}, [loadData]);

useEffect(() => {
getResumeList(1, 100, APP_CONSTANTS.MIN_RESUME_SECTIONS_FOR_SELECTION).then(
(result) => {
if (result?.data) {
setResumes(
result.data.map((r: { id: string; title: string }) => ({
id: r.id,
title: r.title,
})),
);
}
},
);
getResumeList(
1,
100,
APP_CONSTANTS.MIN_RESUME_SECTIONS_FOR_SELECTION,
true,
).then((result) => {
if (result?.data) {
setResumes(
result.data.map((r: { id: string; title: string }) => ({
id: r.id,
title: r.title,
})),
);
}
});
getAutomationsList().then((result) => {
if (result?.data) setAllAutomations(result.data);
});
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/automations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default async function AutomationsPage() {
1,
100,
APP_CONSTANTS.MIN_RESUME_SECTIONS_FOR_SELECTION,
true,
);
const resumes =
resumeResult?.data?.map((r: { id: string; title: string }) => ({
Expand Down
7 changes: 4 additions & 3 deletions src/components/automations/AutomationWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,16 +383,17 @@ export function AutomationWizard({
</SelectContent>
</Select>
<FormDescription>
Jobs will be matched against this resume
Jobs will be matched against this resume&apos;s structured content
or attached document
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{resumes.length === 0 && (
<p className="text-sm text-orange-600 dark:text-orange-500">
No resumes found. Please create a resume with enough content in
your profile first.
No matchable resumes found. Add at least 2 sections or attach a
PDF/DOCX resume in your profile first.
Comment on lines +395 to +396
</p>
)}
</div>
Expand Down
29 changes: 29 additions & 0 deletions src/lib/ai/import/read-resume-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFile } from "fs/promises";
import path from "path";
import { APP_CONSTANTS } from "@/lib/constants";
import { extractText } from "./extract-text";

// Attached resume paths come from the database, but still stay inside the
// configured uploads directory before they are read. This keeps the runner's
// file fallback subject to the same boundary as the import route.
export async function extractAttachedResumeText(
filePath: string | null | undefined,
): Promise<string | null> {
if (!filePath) return null;

const resolvedPath = path.resolve(filePath);
const uploadsDir = path.resolve(APP_CONSTANTS.UPLOADS_DIR);
if (
!resolvedPath.startsWith(`${uploadsDir}${path.sep}`) &&
resolvedPath !== uploadsDir
) {
return null;
}

try {
const result = await extractText(await readFile(resolvedPath));
return result.success ? result.data.text : null;
} catch {
return null;
}
}
Loading