* initial commit * format links right jobops.dakheera47.com/cv/shaheer-google-de * don't support legacy * remove phishing look * smaller links * readiness check in settings * rework UX * right col * pop a modal * modal improvements * show links * documentation disclaimer * fix(tracer-links): preserve descriptive resume link labels * fix(tracer-links): classify bot user agents before browser families * fix(tracer-links): reject non-http redirect destinations * fix(tracer-redirect): disable caching for tracked redirects * fix(origin): prefer canonical public base url over forwarded headers * fix(auth): protect tracer analytics routes behind basic auth * fix(ui): rename misleading tracer drilldown human metric * style(tests): format tracer-links invalid-destination assertion * fix(tests): prevent mocked fs from breaking sqlite data-dir resolution * style(docs): format versioned docs json for biome * fix(tests): mock tracer-links in pdf skills validation suite
442 lines
12 KiB
TypeScript
442 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { generatePdf } from "./pdf";
|
|
import { getProfile } from "./profile";
|
|
|
|
process.env.DATA_DIR = "/tmp";
|
|
|
|
// Define mock data in hoisted block
|
|
const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => {
|
|
const profile = {
|
|
sections: {
|
|
summary: { content: "Original Summary" },
|
|
skills: {
|
|
items: [
|
|
{
|
|
id: "s1",
|
|
name: "Existing Skill",
|
|
visible: true,
|
|
description: "Existing Desc",
|
|
level: 3,
|
|
keywords: ["k1"],
|
|
},
|
|
],
|
|
},
|
|
projects: { items: [] },
|
|
},
|
|
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),
|
|
mkdirSync: vi.fn(),
|
|
createWriteStream: vi.fn().mockReturnValue({
|
|
on: vi.fn(),
|
|
write: vi.fn(),
|
|
end: vi.fn(),
|
|
}),
|
|
default: {
|
|
existsSync: vi.fn().mockReturnValue(true),
|
|
mkdirSync: vi.fn(),
|
|
createWriteStream: vi.fn().mockReturnValue({
|
|
on: vi.fn(),
|
|
write: vi.fn(),
|
|
end: vi.fn(),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("node:fs", () => ({
|
|
existsSync: vi.fn().mockReturnValue(true),
|
|
mkdirSync: vi.fn(),
|
|
createWriteStream: vi.fn().mockReturnValue({
|
|
on: vi.fn(),
|
|
write: vi.fn(),
|
|
end: vi.fn(),
|
|
}),
|
|
default: {
|
|
existsSync: vi.fn().mockReturnValue(true),
|
|
mkdirSync: vi.fn(),
|
|
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("./tracer-links", () => ({
|
|
resolveTracerPublicBaseUrl: vi.fn().mockReturnValue("https://jobops.example"),
|
|
rewriteResumeLinksWithTracer: vi
|
|
.fn()
|
|
.mockResolvedValue({ rewrittenLinks: 0 }),
|
|
}));
|
|
|
|
vi.mock("./resumeProjects", () => ({
|
|
extractProjectsFromProfile: vi
|
|
.fn()
|
|
.mockReturnValue({ catalog: [], selectionItems: [] }),
|
|
resolveResumeProjectsSettings: vi.fn().mockReturnValue({
|
|
resumeProjects: {
|
|
lockedProjectIds: [],
|
|
aiSelectableProjectIds: [],
|
|
maxProjects: 2,
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// 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 for PDF download
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
body: {},
|
|
}),
|
|
);
|
|
|
|
describe("PDF Service Skills Validation", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(getProfile).mockResolvedValue(mockProfile);
|
|
mockRxResumeClient.clearLastCreateData();
|
|
});
|
|
|
|
it("should add required schema fields (visible, description) to new skills", async () => {
|
|
// AI often returns just name and keywords
|
|
const newSkills = [
|
|
{ name: "New Skill", keywords: ["k2"] },
|
|
{ name: "Existing Skill", keywords: ["k3", "k4"] }, // Should merge with s1
|
|
];
|
|
|
|
const tailoredContent = { skills: newSkills };
|
|
|
|
await generatePdf("job-skills-1", tailoredContent, "Job Desc");
|
|
|
|
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
|
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
|
|
|
const skillItems = savedResumeJson.sections.skills.items;
|
|
|
|
// Check "New Skill"
|
|
const newSkill = skillItems.find((s: any) => s.name === "New Skill");
|
|
expect(newSkill).toBeDefined();
|
|
|
|
// These are the validations failing in user report:
|
|
expect(newSkill.visible).toBe(true); // Should default to true
|
|
expect(typeof newSkill.description).toBe("string"); // Should default to ""
|
|
expect(newSkill.description).toBe("");
|
|
// Optional but good to check
|
|
expect(newSkill.id).toBeDefined();
|
|
expect(newSkill.level).toBe(0);
|
|
|
|
// Check "Existing Skill" - should preserve existing fields if not overwritten?
|
|
// In the implementation, we look up existing.
|
|
// existing.visible => true, existing.description => 'Existing Desc', existing.level => 3
|
|
const existingSkill = skillItems.find(
|
|
(s: any) => s.name === "Existing Skill",
|
|
);
|
|
expect(existingSkill.visible).toBe(true);
|
|
expect(existingSkill.description).toBe("Existing Desc");
|
|
expect(existingSkill.level).toBe(3);
|
|
expect(existingSkill.keywords).toEqual(["k3", "k4"]); // Should use new keywords or existing? Implementation uses new || existing.
|
|
});
|
|
|
|
it("should sanitize base resume even if no skills are tailored", async () => {
|
|
// Mock profile has an invalid skill (missing visible/description in the raw json implied,
|
|
// though our mock above has them. Let's make a truly invalid one locally)
|
|
const invalidProfile = {
|
|
...mockProfile,
|
|
sections: {
|
|
...mockProfile.sections,
|
|
skills: {
|
|
...mockProfile.sections.skills,
|
|
items: [
|
|
{
|
|
id: "invalid-1",
|
|
name: "Invalid Skill",
|
|
description: "",
|
|
level: 1,
|
|
keywords: [],
|
|
visible: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} as any;
|
|
vi.mocked(getProfile).mockResolvedValueOnce(invalidProfile);
|
|
|
|
// No tailoring, pass dummy path to bypass getProfile cache and use readFile mock
|
|
await generatePdf("job-no-tailor", {}, "Job Desc", "dummy.json");
|
|
|
|
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
|
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
|
|
|
const item = savedResumeJson.sections.skills.items[0];
|
|
|
|
// Ensure defaults are applied even if we didn't use the tailoring logic block
|
|
expect(item.visible).toBe(true);
|
|
expect(item.description).toBe("");
|
|
expect(item.id).toBeDefined();
|
|
});
|
|
|
|
it("should generate CUID2-compatible IDs for skills without IDs", async () => {
|
|
// Profile with skills missing IDs (common when AI generates them)
|
|
const profileWithoutIds = {
|
|
...mockProfile,
|
|
sections: {
|
|
...mockProfile.sections,
|
|
skills: {
|
|
...mockProfile.sections.skills,
|
|
items: [
|
|
{
|
|
id: "",
|
|
name: "Skill 1",
|
|
keywords: ["a"],
|
|
description: "",
|
|
level: 1,
|
|
visible: true,
|
|
},
|
|
{
|
|
id: "",
|
|
name: "Skill 2",
|
|
keywords: ["b"],
|
|
description: "",
|
|
level: 1,
|
|
visible: true,
|
|
},
|
|
{
|
|
id: "",
|
|
name: "Skill 3",
|
|
keywords: ["c"],
|
|
description: "",
|
|
level: 1,
|
|
visible: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} as any;
|
|
vi.mocked(getProfile).mockResolvedValueOnce(profileWithoutIds);
|
|
|
|
await generatePdf("job-cuid2-test", {}, "Job Desc", "dummy.json");
|
|
|
|
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
|
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
|
|
|
const skillItems = savedResumeJson.sections.skills.items;
|
|
|
|
// All skills should have IDs
|
|
skillItems.forEach((skill: any, _index: number) => {
|
|
expect(skill.id).toBeDefined();
|
|
expect(typeof skill.id).toBe("string");
|
|
expect(skill.id.length).toBeGreaterThanOrEqual(20);
|
|
|
|
// CUID2 format: starts with a letter, lowercase alphanumeric
|
|
expect(skill.id).toMatch(/^[a-z][a-z0-9]+$/);
|
|
});
|
|
|
|
// IDs should be unique
|
|
const ids = skillItems.map((s: any) => s.id);
|
|
const uniqueIds = new Set(ids);
|
|
expect(uniqueIds.size).toBe(ids.length);
|
|
});
|
|
|
|
it('should NOT generate IDs like "skill-0" which are invalid CUID2', async () => {
|
|
const profileWithoutIds = {
|
|
...mockProfile,
|
|
sections: {
|
|
...mockProfile.sections,
|
|
skills: {
|
|
...mockProfile.sections.skills,
|
|
items: [
|
|
{
|
|
id: "",
|
|
name: "Skill Without ID",
|
|
keywords: ["test"],
|
|
description: "",
|
|
level: 1,
|
|
visible: true,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
} as any;
|
|
vi.mocked(getProfile).mockResolvedValueOnce(profileWithoutIds);
|
|
|
|
await generatePdf("job-no-skill-prefix", {}, "Job Desc", "dummy.json");
|
|
|
|
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
|
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
|
|
|
const skill = savedResumeJson.sections.skills.items[0];
|
|
|
|
// ID should NOT be in the old invalid format
|
|
expect(skill.id).not.toMatch(/^skill-\d+$/);
|
|
|
|
// Should be valid CUID2 format
|
|
expect(skill.id).toMatch(/^[a-z][a-z0-9]+$/);
|
|
});
|
|
|
|
it("should preserve existing valid IDs and not regenerate them", async () => {
|
|
const validCuid2Id = "ck9w4ygzq0000xmn5h0jt7l5c";
|
|
const profileWithValidId = {
|
|
...mockProfile,
|
|
sections: {
|
|
...mockProfile.sections,
|
|
skills: {
|
|
items: [
|
|
{
|
|
id: validCuid2Id,
|
|
name: "Skill With Valid ID",
|
|
keywords: ["test"],
|
|
visible: true,
|
|
description: "",
|
|
level: 1,
|
|
},
|
|
],
|
|
},
|
|
},
|
|
};
|
|
vi.mocked(getProfile).mockResolvedValueOnce(profileWithValidId);
|
|
|
|
await generatePdf("job-preserve-id", {}, "Job Desc", "dummy.json");
|
|
|
|
expect(mockRxResumeClient.create).toHaveBeenCalled();
|
|
const savedResumeJson = mockRxResumeClient.getLastCreateData();
|
|
|
|
const skill = savedResumeJson.sections.skills.items[0];
|
|
|
|
// Should preserve the original valid ID
|
|
expect(skill.id).toBe(validCuid2Id);
|
|
});
|
|
});
|