Jobber/orchestrator/src/server/services/pdf-tailoring.test.ts
Shaheer Sarfaraz b88d00b15d
Make projects optional when moving jobs to Ready (#189)
* Make resume projects optional and reuse selection rules

* Apply Biome import/format fixes

* Handle explicit empty project selection in PDF generation

* Hide selected projects section when catalog is empty

* Avoid projects section flash while catalog is loading
2026-02-18 22:31:59 +00:00

338 lines
9.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { generatePdf } from "./pdf";
import * as projectSelection from "./projectSelection";
// Define mock data in hoisted block
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
const profile = {
sections: {
summary: { content: "Original Summary" },
skills: { items: ["Original Skill"] },
projects: {
items: [
// Start with visible=true to test if they get hidden
{ id: "p1", name: "Project 1", visible: true },
{ id: "p2", name: "Project 2", visible: true },
],
},
},
basics: { headline: "Original Headline" },
};
// Capture what's passed to create()
let lastCreateData: any = null;
const mockClient = {
create: vi.fn().mockImplementation((data: any) => {
lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone
return Promise.resolve("mock-resume-id");
}),
print: vi.fn().mockResolvedValue("https://example.com/pdf/mock.pdf"),
delete: vi.fn().mockResolvedValue(undefined),
withAutoRefresh: vi
.fn()
.mockImplementation(
async (
_email: string,
_password: string,
operation: (token: string) => Promise<any>,
) => {
return operation("mock-token");
},
),
getToken: vi.fn().mockResolvedValue("mock-token"),
getLastCreateData: () => lastCreateData,
clearLastCreateData: () => {
lastCreateData = null;
},
};
return {
mockProfile: profile,
mocks: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
},
mockRxResumeClient: mockClient,
};
});
// Configure base mock implementations
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
mocks.writeFile.mockResolvedValue(undefined);
vi.mock("fs/promises", async () => {
return {
default: mocks,
...mocks,
};
});
vi.mock("node:fs/promises", async () => {
return {
default: mocks,
...mocks,
};
});
vi.mock("fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
}),
default: {
existsSync: vi.fn().mockReturnValue(true),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
}),
},
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
}),
default: {
existsSync: vi.fn().mockReturnValue(true),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn(),
write: vi.fn(),
end: vi.fn(),
}),
},
}));
vi.mock("../repositories/settings", () => ({
getSetting: vi.fn().mockImplementation((key: string) => {
if (key === "rxresumeEmail") return Promise.resolve("test@example.com");
if (key === "rxresumePassword") return Promise.resolve("testpassword");
return Promise.resolve(null);
}),
getAllSettings: vi.fn().mockResolvedValue({}),
}));
// Mock the profile service - getProfile now fetches from v4 API
vi.mock("./profile", () => ({
getProfile: vi.fn().mockResolvedValue(mockProfile),
}));
vi.mock("./projectSelection", () => ({
pickProjectIdsForJob: vi.fn().mockResolvedValue([]),
}));
vi.mock("./resumeProjects", () => ({
extractProjectsFromProfile: vi.fn().mockReturnValue({
catalog: [],
selectionItems: [
{ id: "p1", name: "Project 1" },
{ id: "p2", name: "Project 2" },
],
}),
resolveResumeProjectsSettings: vi.fn().mockReturnValue({
resumeProjects: {
lockedProjectIds: [],
aiSelectableProjectIds: ["p1", "p2"],
maxProjects: 3,
},
}),
}));
const mockTracerLinks = vi.hoisted(() => ({
resolveTracerPublicBaseUrl: vi.fn().mockReturnValue("https://jobops.example"),
rewriteResumeLinksWithTracer: vi
.fn()
.mockResolvedValue({ rewrittenLinks: 2 }),
}));
vi.mock("./tracer-links", () => ({
resolveTracerPublicBaseUrl: mockTracerLinks.resolveTracerPublicBaseUrl,
rewriteResumeLinksWithTracer: mockTracerLinks.rewriteResumeLinksWithTracer,
}));
// Mock the RxResumeClient
vi.mock("./rxresume-client", () => ({
RxResumeClient: vi.fn().mockImplementation(function (this: any) {
return mockRxResumeClient;
}),
}));
// Mock stream pipeline for downloading PDF
vi.mock("stream/promises", () => ({
pipeline: vi.fn().mockResolvedValue(undefined),
default: {
pipeline: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("node:stream/promises", () => ({
pipeline: vi.fn().mockResolvedValue(undefined),
default: {
pipeline: vi.fn().mockResolvedValue(undefined),
},
}));
// Mock stream Readable
vi.mock("stream", () => ({
Readable: {
fromWeb: vi.fn().mockReturnValue({
pipe: vi.fn(),
}),
},
default: {
Readable: {
fromWeb: vi.fn().mockReturnValue({
pipe: vi.fn(),
}),
},
},
}));
vi.mock("node:stream", () => ({
Readable: {
fromWeb: vi.fn().mockReturnValue({
pipe: vi.fn(),
}),
},
default: {
Readable: {
fromWeb: vi.fn().mockReturnValue({
pipe: vi.fn(),
}),
},
},
}));
// Mock global fetch
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
body: {},
}),
);
describe("PDF Service Tailoring Logic", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile));
mockRxResumeClient.clearLastCreateData();
mockTracerLinks.resolveTracerPublicBaseUrl.mockReturnValue(
"https://jobops.example",
);
mockTracerLinks.rewriteResumeLinksWithTracer.mockResolvedValue({
rewrittenLinks: 2,
});
});
it("should use provided selectedProjectIds and BYPASS AI selection", async () => {
const tailoredContent = {
summary: "New Sum",
headline: "New Head",
skills: [],
};
await generatePdf("job-1", tailoredContent, "Job Desc", "base.json", "p2");
// 1. pickProjectIdsForJob should NOT be called
expect(projectSelection.pickProjectIdsForJob).not.toHaveBeenCalled();
// 2. Verify create data content
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const projects = savedResumeJson.sections.projects.items;
const p1 = projects.find((p: any) => p.id === "p1");
const p2 = projects.find((p: any) => p.id === "p2");
expect(p2.visible).toBe(true);
expect(p1.visible).toBe(false);
// 3. Verify Summary Update
const summary = savedResumeJson.sections.summary.content;
expect(summary).toBe("New Sum");
});
it("should handle comma-separated project IDs correctly", async () => {
await generatePdf("job-2", {}, "desc", "base.json", "p1, p2 ");
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const projects = savedResumeJson.sections.projects.items;
expect(projects.find((p: any) => p.id === "p1").visible).toBe(true);
expect(projects.find((p: any) => p.id === "p2").visible).toBe(true);
});
it("keeps projects section visible when selected project list is explicitly empty", async () => {
await generatePdf("job-empty-projects", {}, "desc", "base.json", "");
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const projects = savedResumeJson.sections.projects.items;
expect(projects.find((p: any) => p.id === "p1").visible).toBe(false);
expect(projects.find((p: any) => p.id === "p2").visible).toBe(false);
expect(savedResumeJson.sections.projects.visible).toBe(true);
});
it("should fall back to AI selection if selectedProjectIds is null/undefined", async () => {
// Setup AI selection mock for this test
vi.mocked(projectSelection.pickProjectIdsForJob).mockResolvedValue(["p1"]);
await generatePdf("job-3", {}, "desc", "base.json", undefined);
expect(projectSelection.pickProjectIdsForJob).toHaveBeenCalled();
expect(mockRxResumeClient.create).toHaveBeenCalled();
const savedResumeJson = mockRxResumeClient.getLastCreateData();
const p1 = savedResumeJson.sections.projects.items.find(
(p: any) => p.id === "p1",
);
const p2 = savedResumeJson.sections.projects.items.find(
(p: any) => p.id === "p2",
);
expect(p1.visible).toBe(true);
expect(p2.visible).toBe(false);
const visibleCount = savedResumeJson.sections.projects.items.filter(
(p: any) => p.visible,
).length;
expect(visibleCount).toBe(1);
});
it("does not rewrite links when tracer links are disabled", async () => {
await generatePdf("job-no-tracer", {}, "desc", undefined, undefined, {
tracerLinksEnabled: false,
});
expect(mockTracerLinks.resolveTracerPublicBaseUrl).not.toHaveBeenCalled();
expect(mockTracerLinks.rewriteResumeLinksWithTracer).not.toHaveBeenCalled();
});
it("rewrites links when tracer links are enabled", async () => {
await generatePdf("job-with-tracer", {}, "desc", undefined, undefined, {
tracerLinksEnabled: true,
requestOrigin: "https://jobops.example",
});
expect(mockTracerLinks.resolveTracerPublicBaseUrl).toHaveBeenCalledWith({
requestOrigin: "https://jobops.example",
});
expect(mockTracerLinks.rewriteResumeLinksWithTracer).toHaveBeenCalledTimes(
1,
);
});
});