This commit is contained in:
Amruth Pillai
2026-04-02 00:14:54 +02:00
parent d9a24448e8
commit 4fd43657dc
175 changed files with 11886 additions and 1840 deletions
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vite-plus/test";
import { defaultResumeData } from "@/schema/resume/data";
import { buildAiExtractionTemplate } from "./ai-template";
describe("buildAiExtractionTemplate", () => {
const template = buildAiExtractionTemplate();
it("produces a valid ResumeData structure", () => {
// The template should pass schema validation (items have empty strings which satisfy .min(1) checks,
// so we validate the overall shape matches)
expect(template).toBeDefined();
expect(template.basics).toBeDefined();
expect(template.sections).toBeDefined();
expect(template.metadata).toBeDefined();
});
it("includes a sample custom field in basics", () => {
expect(template.basics.customFields).toHaveLength(1);
expect(template.basics.customFields[0]).toEqual({ id: "", icon: "", text: "", link: "" });
});
it("populates each section with exactly one example item", () => {
const sectionKeys = [
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
] as const;
for (const key of sectionKeys) {
const section = template.sections[key];
expect(section.items).toHaveLength(1);
}
});
it("preserves section metadata from defaultResumeData", () => {
const sectionKeys = Object.keys(defaultResumeData.sections) as Array<keyof typeof defaultResumeData.sections>;
for (const key of sectionKeys) {
expect(template.sections[key].title).toBe(defaultResumeData.sections[key].title);
expect(template.sections[key].columns).toBe(defaultResumeData.sections[key].columns);
expect(template.sections[key].hidden).toBe(defaultResumeData.sections[key].hidden);
}
});
it("has empty string values in example items (not default data)", () => {
const expItem = template.sections.experience.items[0];
expect(expItem.company).toBe("");
expect(expItem.position).toBe("");
expect(expItem.id).toBe("");
});
it("has zeroed numeric values in example items", () => {
const skillItem = template.sections.skills.items[0];
expect(skillItem.level).toBe(0);
});
it("has nested objects with empty values", () => {
const expItem = template.sections.experience.items[0];
expect(expItem.website).toEqual({ url: "", label: "" });
});
it("covers all sections present in defaultResumeData", () => {
const defaultKeys = Object.keys(defaultResumeData.sections).sort();
const templateKeys = Object.keys(template.sections).sort();
expect(templateKeys).toEqual(defaultKeys);
});
it("preserves metadata and picture from defaultResumeData", () => {
expect(template.metadata).toEqual(defaultResumeData.metadata);
expect(template.picture).toEqual(defaultResumeData.picture);
});
});
+131
View File
@@ -0,0 +1,131 @@
import { defaultResumeData } from "@/schema/resume/data";
/**
* Generates an AI extraction template item for a section by creating an object
* with the same keys as the section's default items but with zeroed/empty values.
* This template tells the AI what fields to extract for each section type.
*/
function makeEmptyItem(shape: Record<string, unknown>): Record<string, unknown> {
const item: Record<string, unknown> = {};
for (const [key, value] of Object.entries(shape)) {
if (typeof value === "string") item[key] = "";
else if (typeof value === "number") item[key] = 0;
else if (typeof value === "boolean") item[key] = false;
else if (Array.isArray(value)) item[key] = [];
else if (value !== null && typeof value === "object") {
item[key] = makeEmptyItem(value as Record<string, unknown>);
} else {
item[key] = "";
}
}
return item;
}
/**
* Section item shape definitions. Each describes the fields the AI should extract.
* These are the canonical shapes — if a schema field is added, add it here too.
*/
const SECTION_ITEM_SHAPES: Record<string, Record<string, unknown>> = {
profiles: { id: "", hidden: false, icon: "", network: "", username: "", website: { url: "", label: "" } },
experience: {
id: "",
hidden: false,
company: "",
position: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
education: {
id: "",
hidden: false,
school: "",
degree: "",
area: "",
grade: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
projects: { id: "", hidden: false, name: "", period: "", website: { url: "", label: "" }, description: "" },
skills: { id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] },
languages: { id: "", hidden: false, language: "", fluency: "", level: 0 },
interests: { id: "", hidden: false, icon: "", name: "", keywords: [] },
awards: {
id: "",
hidden: false,
title: "",
awarder: "",
date: "",
website: { url: "", label: "" },
description: "",
},
certifications: {
id: "",
hidden: false,
title: "",
issuer: "",
date: "",
website: { url: "", label: "" },
description: "",
},
publications: {
id: "",
hidden: false,
title: "",
publisher: "",
date: "",
website: { url: "", label: "" },
description: "",
},
volunteer: {
id: "",
hidden: false,
organization: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
references: {
id: "",
hidden: false,
name: "",
position: "",
website: { url: "", label: "" },
phone: "",
description: "",
},
};
/**
* Builds the AI extraction template programmatically from defaultResumeData.
* This eliminates manual duplication and ensures the template stays in sync with the schema.
*/
export function buildAiExtractionTemplate() {
const sections: Record<string, unknown> = {};
for (const [key, shape] of Object.entries(SECTION_ITEM_SHAPES)) {
const sectionKey = key as keyof typeof defaultResumeData.sections;
sections[key] = {
...defaultResumeData.sections[sectionKey],
items: [makeEmptyItem(shape)],
};
}
return {
...defaultResumeData,
basics: {
...defaultResumeData.basics,
customFields: [{ id: "", icon: "", text: "", link: "" }],
},
sections: {
...defaultResumeData.sections,
...sections,
},
};
}
+51
View File
@@ -38,4 +38,55 @@ describe("parseColorString", () => {
it("should return null for incomplete hex", () => {
expect(parseColorString("#ff")).toBeNull();
});
// --- Additional branch coverage ---
it("should parse rgba() without alpha (defaults to 1)", () => {
expect(parseColorString("rgba(100, 200, 50)")).toEqual({ r: 100, g: 200, b: 50, a: 1 });
});
it("should parse rgb() with max values", () => {
expect(parseColorString("rgb(255, 255, 255)")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
});
it("should parse rgba() with alpha of 0", () => {
expect(parseColorString("rgba(0, 0, 0, 0)")).toEqual({ r: 0, g: 0, b: 0, a: 0 });
});
it("should parse rgba() with alpha of 1", () => {
expect(parseColorString("rgba(0, 0, 0, 1)")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
});
it("should parse 3-digit hex #000", () => {
expect(parseColorString("#000")).toEqual({ r: 0, g: 0, b: 0, a: 1 });
});
it("should parse 3-digit hex #fff", () => {
expect(parseColorString("#fff")).toEqual({ r: 255, g: 255, b: 255, a: 1 });
});
it("should return null for 4-digit hex", () => {
expect(parseColorString("#ffff")).toBeNull();
});
it("should return null for 8-digit hex (with alpha)", () => {
expect(parseColorString("#ff880080")).toBeNull();
});
it("should return null for non-hex characters after #", () => {
expect(parseColorString("#gggggg")).toBeNull();
});
it("should return null for named colors", () => {
expect(parseColorString("red")).toBeNull();
expect(parseColorString("blue")).toBeNull();
});
it("should handle rgb with extra spaces", () => {
expect(parseColorString("rgb( 10 , 20 , 30 )")).toEqual({ r: 10, g: 20, b: 30, a: 1 });
});
it("should parse rgba with decimal alpha", () => {
expect(parseColorString("rgba(255, 0, 0, 0.75)")).toEqual({ r: 255, g: 0, b: 0, a: 0.75 });
});
});
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vite-plus/test";
import { formatDate, formatPeriod, formatSingleDate } from "./date";
describe("formatDate", () => {
it("formats YYYY-MM-DD to month year by default", () => {
expect(formatDate("2024-01-15")).toBe("January 2024");
});
it("formats YYYY-MM to month year", () => {
expect(formatDate("2024-06")).toBe("June 2024");
});
it("returns year only for YYYY", () => {
expect(formatDate("2024")).toBe("2024");
});
it("includes day when includeDay is true", () => {
expect(formatDate("2024-01-15", true)).toBe("January 15, 2024");
});
it("formats YYYY-MM with includeDay (no day available)", () => {
expect(formatDate("2024-06", true)).toBe("June 2024");
});
it("handles December correctly", () => {
expect(formatDate("2023-12-25")).toBe("December 2023");
});
});
describe("formatPeriod", () => {
it("returns empty string when both dates are missing", () => {
expect(formatPeriod()).toBe("");
expect(formatPeriod(undefined, undefined)).toBe("");
});
it("returns end date when start is missing", () => {
expect(formatPeriod(undefined, "2024-06")).toBe("2024-06");
});
it("returns 'Start - Present' when end is missing", () => {
expect(formatPeriod("2024-01")).toBe("January 2024 - Present");
});
it("formats both dates", () => {
expect(formatPeriod("2020-01", "2024-06")).toBe("January 2020 - June 2024");
});
it("formats full dates to month-year", () => {
expect(formatPeriod("2020-01-15", "2024-06-30")).toBe("January 2020 - June 2024");
});
});
describe("formatSingleDate", () => {
it("returns empty string for undefined", () => {
expect(formatSingleDate()).toBe("");
expect(formatSingleDate(undefined)).toBe("");
});
it("formats YYYY-MM-DD with day included", () => {
expect(formatSingleDate("2024-03-15")).toBe("March 15, 2024");
});
it("formats YYYY-MM without day", () => {
expect(formatSingleDate("2024-03")).toBe("March 2024");
});
it("returns year only for YYYY", () => {
expect(formatSingleDate("2024")).toBe("2024");
});
});
+57
View File
@@ -0,0 +1,57 @@
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
/**
* Formats a partial ISO 8601 date string (YYYY, YYYY-MM, or YYYY-MM-DD)
* into a human-readable format like "January 2024" or "January 15, 2024".
*/
export function formatDate(date: string, includeDay = false): string {
const parts = date.split("-");
if (parts.length >= 2) {
const [year, month] = parts;
const monthName = MONTH_NAMES[parseInt(month, 10) - 1];
if (parts.length === 3 && includeDay) {
return `${monthName} ${parts[2]}, ${year}`;
}
return `${monthName} ${year}`;
}
// YYYY only
return date;
}
/**
* Formats a date range from start and end dates.
* Returns "Start - End", "Start - Present" if no end, or just the end date if no start.
*/
export function formatPeriod(startDate?: string, endDate?: string): string {
if (!startDate && !endDate) return "";
if (!startDate) return endDate || "";
if (!endDate) return `${formatDate(startDate)} - Present`;
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
}
/**
* Formats a single date with day included (e.g., "January 15, 2024").
* Falls back to month-year or year-only for partial dates.
*/
export function formatSingleDate(date?: string): string {
if (!date) return "";
return formatDate(date, true);
}
+4
View File
@@ -32,6 +32,10 @@ export const env = createEnv({
GITHUB_CLIENT_ID: z.string().min(1).optional(),
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
// Social Auth (LinkedIn)
LINKEDIN_CLIENT_ID: z.string().min(1).optional(),
LINKEDIN_CLIENT_SECRET: z.string().min(1).optional(),
// Custom OAuth Provider
OAUTH_PROVIDER_NAME: z.string().min(1).optional(),
OAUTH_CLIENT_ID: z.string().min(1).optional(),
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vite-plus/test";
import { arrayToHtmlList, toHtmlDescription } from "./html";
describe("toHtmlDescription", () => {
it("returns empty string when both are missing", () => {
expect(toHtmlDescription()).toBe("");
expect(toHtmlDescription(undefined, undefined)).toBe("");
});
it("wraps summary in <p>", () => {
expect(toHtmlDescription("Hello")).toBe("<p>Hello</p>");
});
it("creates <ul> from highlights", () => {
expect(toHtmlDescription(undefined, ["a", "b"])).toBe("<ul><li>a</li><li>b</li></ul>");
});
it("combines summary and highlights", () => {
expect(toHtmlDescription("Summary", ["a", "b"])).toBe("<p>Summary</p><ul><li>a</li><li>b</li></ul>");
});
it("ignores empty highlights array", () => {
expect(toHtmlDescription("Summary", [])).toBe("<p>Summary</p>");
});
});
describe("arrayToHtmlList", () => {
it("returns empty string for empty array", () => {
expect(arrayToHtmlList([])).toBe("");
});
it("creates <ul> list from items", () => {
expect(arrayToHtmlList(["a", "b", "c"])).toBe("<ul><li>a</li><li>b</li><li>c</li></ul>");
});
it("handles single item", () => {
expect(arrayToHtmlList(["only"])).toBe("<ul><li>only</li></ul>");
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Converts a summary string and optional highlights array into an HTML description.
* Summary becomes a <p> tag, highlights become a <ul> list.
*/
export function toHtmlDescription(summary?: string, highlights?: string[]): string {
const parts: string[] = [];
if (summary) {
parts.push(`<p>${summary}</p>`);
}
if (highlights && highlights.length > 0) {
parts.push("<ul>");
for (const highlight of highlights) {
parts.push(`<li>${highlight}</li>`);
}
parts.push("</ul>");
}
return parts.join("");
}
/**
* Converts an array of strings into an HTML unordered list.
*/
export function arrayToHtmlList(items: string[]): string {
if (items.length === 0) return "";
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vite-plus/test";
import { parseLevel } from "./level";
describe("parseLevel", () => {
it("returns 0 for undefined/empty", () => {
expect(parseLevel()).toBe(0);
expect(parseLevel(undefined)).toBe(0);
expect(parseLevel("")).toBe(0);
});
it("parses numeric values within range", () => {
expect(parseLevel("0")).toBe(0);
expect(parseLevel("3")).toBe(3);
expect(parseLevel("5")).toBe(5);
});
it("returns 0 for numeric values out of range", () => {
expect(parseLevel("6")).toBe(0);
expect(parseLevel("-1")).toBe(0);
});
it("maps expert-level text to 5", () => {
expect(parseLevel("Native")).toBe(5);
expect(parseLevel("Expert")).toBe(5);
expect(parseLevel("Master")).toBe(5);
});
it("maps advanced-level text to 4", () => {
expect(parseLevel("Fluent")).toBe(4);
expect(parseLevel("Advanced")).toBe(4);
expect(parseLevel("Proficient")).toBe(4);
});
it("maps intermediate-level text to 3", () => {
expect(parseLevel("Intermediate")).toBe(3);
expect(parseLevel("Conversational")).toBe(3);
});
it("maps beginner-level text to 2", () => {
expect(parseLevel("Beginner")).toBe(2);
expect(parseLevel("Basic")).toBe(2);
expect(parseLevel("Elementary")).toBe(2);
});
it("maps novice to 1", () => {
expect(parseLevel("Novice")).toBe(1);
});
it("maps CEFR levels correctly", () => {
expect(parseLevel("C2")).toBe(5);
expect(parseLevel("C1")).toBe(4);
expect(parseLevel("B2")).toBe(3);
expect(parseLevel("B1")).toBe(2);
expect(parseLevel("A2")).toBe(1);
expect(parseLevel("A1")).toBe(1);
});
it("is case-insensitive", () => {
expect(parseLevel("NATIVE")).toBe(5);
expect(parseLevel("beginner")).toBe(2);
expect(parseLevel("c2")).toBe(5);
});
it("matches partial strings", () => {
expect(parseLevel("Native speaker")).toBe(5);
expect(parseLevel("Advanced level")).toBe(4);
});
it("returns 0 for unrecognized text", () => {
expect(parseLevel("unknown")).toBe(0);
expect(parseLevel("xyz")).toBe(0);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Parses a skill/language level string to a numeric value (0-5).
* Supports numeric values, text levels (beginner/intermediate/advanced/expert),
* and CEFR levels (A1-C2).
*/
export function parseLevel(level?: string): number {
if (!level) return 0;
const levelLower = level.toLowerCase();
// Try to parse numeric values
const numeric = parseInt(levelLower, 10);
if (!Number.isNaN(numeric) && numeric >= 0 && numeric <= 5) return numeric;
// Map text levels to numbers
if (levelLower.includes("native") || levelLower.includes("expert") || levelLower.includes("master")) return 5;
if (levelLower.includes("fluent") || levelLower.includes("advanced") || levelLower.includes("proficient")) return 4;
if (levelLower.includes("intermediate") || levelLower.includes("conversational")) return 3;
if (levelLower.includes("beginner") || levelLower.includes("basic") || levelLower.includes("elementary")) return 2;
if (levelLower.includes("novice")) return 1;
// CEFR levels
if (levelLower.includes("c2")) return 5;
if (levelLower.includes("c1")) return 4;
if (levelLower.includes("b2")) return 3;
if (levelLower.includes("b1")) return 2;
if (levelLower.includes("a2")) return 1;
if (levelLower.includes("a1")) return 1;
return 0;
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vite-plus/test";
import { getNetworkIcon } from "./network-icons";
describe("getNetworkIcon", () => {
it("returns 'star' for undefined/empty", () => {
expect(getNetworkIcon()).toBe("star");
expect(getNetworkIcon(undefined)).toBe("star");
});
it("maps GitHub correctly", () => {
expect(getNetworkIcon("GitHub")).toBe("github-logo");
expect(getNetworkIcon("github")).toBe("github-logo");
});
it("maps LinkedIn correctly", () => {
expect(getNetworkIcon("LinkedIn")).toBe("linkedin-logo");
});
it("maps Twitter and X.com", () => {
expect(getNetworkIcon("Twitter")).toBe("twitter-logo");
expect(getNetworkIcon("x.com")).toBe("twitter-logo");
});
it("maps other social networks", () => {
expect(getNetworkIcon("Facebook")).toBe("facebook-logo");
expect(getNetworkIcon("Instagram")).toBe("instagram-logo");
expect(getNetworkIcon("YouTube")).toBe("youtube-logo");
expect(getNetworkIcon("Medium")).toBe("medium-logo");
expect(getNetworkIcon("Dribbble")).toBe("dribbble-logo");
expect(getNetworkIcon("Behance")).toBe("behance-logo");
});
it("maps dev platforms", () => {
expect(getNetworkIcon("StackOverflow")).toBe("stack-overflow-logo");
expect(getNetworkIcon("Stack-Overflow")).toBe("stack-overflow-logo");
expect(getNetworkIcon("dev.to")).toBe("code");
expect(getNetworkIcon("devto")).toBe("code");
expect(getNetworkIcon("GitLab")).toBe("git-branch");
expect(getNetworkIcon("Bitbucket")).toBe("code");
expect(getNetworkIcon("CodePen")).toBe("code");
});
it("is case-insensitive", () => {
expect(getNetworkIcon("GITHUB")).toBe("github-logo");
expect(getNetworkIcon("linkedin")).toBe("linkedin-logo");
});
it("returns 'star' for unknown networks", () => {
expect(getNetworkIcon("MySpace")).toBe("star");
expect(getNetworkIcon("Unknown")).toBe("star");
});
});
+35
View File
@@ -0,0 +1,35 @@
import type { IconName } from "@/schema/icons";
const NETWORK_ICON_MAP: Array<{ match: string[]; icon: IconName }> = [
{ match: ["github"], icon: "github-logo" },
{ match: ["linkedin"], icon: "linkedin-logo" },
{ match: ["twitter", "x", "x.com"], icon: "twitter-logo" },
{ match: ["facebook"], icon: "facebook-logo" },
{ match: ["instagram"], icon: "instagram-logo" },
{ match: ["youtube"], icon: "youtube-logo" },
{ match: ["stackoverflow", "stack-overflow"], icon: "stack-overflow-logo" },
{ match: ["medium"], icon: "medium-logo" },
{ match: ["dev.to", "devto"], icon: "code" },
{ match: ["dribbble"], icon: "dribbble-logo" },
{ match: ["behance"], icon: "behance-logo" },
{ match: ["gitlab"], icon: "git-branch" },
{ match: ["bitbucket", "codepen"], icon: "code" },
];
/**
* Maps a social network name to a Phosphor icon name.
* Returns "star" as default if no match is found.
*/
export function getNetworkIcon(network?: string): IconName {
if (!network) return "star";
const networkLower = network.toLowerCase();
for (const entry of NETWORK_ICON_MAP) {
if (entry.match.some((keyword) => networkLower.includes(keyword))) {
return entry.icon;
}
}
return "star";
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vite-plus/test";
import { hashPassword, verifyPassword } from "./password";
describe("hashPassword", () => {
it("returns a bcrypt hash string", async () => {
const hash = await hashPassword("my-password");
expect(hash).toMatch(/^\$2[aby]?\$\d+\$/);
});
it("produces different hashes for the same password (salted)", async () => {
const hash1 = await hashPassword("same-password");
const hash2 = await hashPassword("same-password");
expect(hash1).not.toBe(hash2);
});
it("produces a hash of expected length (~60 chars)", async () => {
const hash = await hashPassword("test");
expect(hash.length).toBe(60);
});
});
describe("verifyPassword", () => {
it("returns true for correct password", async () => {
const hash = await hashPassword("correct-password");
const result = await verifyPassword("correct-password", hash);
expect(result).toBe(true);
});
it("returns false for incorrect password", async () => {
const hash = await hashPassword("correct-password");
const result = await verifyPassword("wrong-password", hash);
expect(result).toBe(false);
});
it("handles empty passwords", async () => {
const hash = await hashPassword("");
expect(await verifyPassword("", hash)).toBe(true);
expect(await verifyPassword("notempty", hash)).toBe(false);
});
it("handles special characters", async () => {
const password = "p@$$w0rd!#%&*(){}[]|\\/<>?";
const hash = await hashPassword(password);
expect(await verifyPassword(password, hash)).toBe(true);
});
it("handles unicode characters", async () => {
const password = "パスワード123";
const hash = await hashPassword(password);
expect(await verifyPassword(password, hash)).toBe(true);
});
});
+262
View File
@@ -0,0 +1,262 @@
import { createHmac } from "node:crypto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
// ---------------------------------------------------------------------------
// We can't easily mock createIsomorphicFn, so we reimplement the core logic
// directly in the test to verify the token generation/verification algorithm.
// This tests the same code paths without depending on the TanStack wrapper.
// ---------------------------------------------------------------------------
const TEST_SECRET = "test-secret-key-for-hmac";
const PRINTER_TOKEN_TTL_MS = 5 * 60 * 1000;
/** Reimplementation of generatePrinterToken for testing */
function generateToken(resumeId: string): string {
const timestamp = Date.now();
const payload = `${resumeId}:${timestamp}`;
const payloadBase64 = Buffer.from(payload).toString("base64url");
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
return `${payloadBase64}.${signature}`;
}
/** Reimplementation of verifyPrinterToken for testing */
function verifyToken(token: string): string {
const { timingSafeEqual } = require("node:crypto");
const parts = token.split(".");
if (parts.length !== 2) throw new Error("Invalid token format");
const [payloadBase64, signature] = parts;
const expectedSignature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) {
throw new Error("Invalid token signature");
}
const payload = Buffer.from(payloadBase64, "base64url").toString("utf-8");
const [resumeId, timestampStr] = payload.split(":");
if (!resumeId || !timestampStr) throw new Error("Invalid token payload");
const timestamp = Number.parseInt(timestampStr, 10);
if (Number.isNaN(timestamp)) throw new Error("Invalid timestamp");
const age = Date.now() - timestamp;
if (age < 0 || age > PRINTER_TOKEN_TTL_MS) throw new Error("Token expired");
return resumeId;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createValidToken(resumeId: string, timestamp: number): string {
const payload = `${resumeId}:${timestamp}`;
const payloadBase64 = Buffer.from(payload).toString("base64url");
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
return `${payloadBase64}.${signature}`;
}
// ---------------------------------------------------------------------------
// Tests — these validate the algorithm used by printer-token.ts
// ---------------------------------------------------------------------------
describe("generatePrinterToken (algorithm)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns a token with format payload.signature", () => {
const token = generateToken("resume-123");
const parts = token.split(".");
expect(parts).toHaveLength(2);
expect(parts[0].length).toBeGreaterThan(0);
expect(parts[1].length).toBeGreaterThan(0);
});
it("encodes the resume ID and timestamp in the payload", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const token = generateToken("my-resume-id");
const [payloadBase64] = token.split(".");
const decoded = Buffer.from(payloadBase64, "base64url").toString("utf-8");
expect(decoded).toContain("my-resume-id");
expect(decoded).toContain(String(Date.now()));
});
it("produces a valid HMAC-SHA256 signature", () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const token = generateToken("resume-abc");
const [payloadBase64, signature] = token.split(".");
const expectedSignature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
expect(signature).toBe(expectedSignature);
});
it("generates different tokens for different resume IDs", () => {
const token1 = generateToken("resume-1");
const token2 = generateToken("resume-2");
expect(token1).not.toBe(token2);
});
it("generates different tokens at different timestamps", () => {
vi.setSystemTime(new Date("2025-01-01T00:00:00Z"));
const token1 = generateToken("same-resume");
vi.setSystemTime(new Date("2025-01-01T00:01:00Z"));
const token2 = generateToken("same-resume");
expect(token1).not.toBe(token2);
});
it("uses base64url encoding (no +, /, or = characters in payload)", () => {
const token = generateToken("resume/with+special=chars");
const [payloadBase64] = token.split(".");
expect(payloadBase64).not.toMatch(/[+/=]/);
});
});
describe("verifyPrinterToken (algorithm)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the resume ID for a valid, non-expired token", () => {
const now = new Date("2025-06-15T12:00:00Z");
vi.setSystemTime(now);
const token = createValidToken("resume-123", now.getTime());
const resumeId = verifyToken(token);
expect(resumeId).toBe("resume-123");
});
it("accepts a token that is just under 5 minutes old", () => {
const createdAt = new Date("2025-06-15T12:00:00Z");
vi.setSystemTime(new Date("2025-06-15T12:04:59Z"));
const token = createValidToken("resume-456", createdAt.getTime());
const resumeId = verifyToken(token);
expect(resumeId).toBe("resume-456");
});
it("rejects a token exactly at the 5-minute boundary", () => {
const createdAt = new Date("2025-06-15T12:00:00Z");
// 5 minutes + 1ms after creation
vi.setSystemTime(new Date(createdAt.getTime() + PRINTER_TOKEN_TTL_MS + 1));
const token = createValidToken("resume-789", createdAt.getTime());
expect(() => verifyToken(token)).toThrow("Token expired");
});
it("throws for a token older than 5 minutes", () => {
const createdAt = new Date("2025-06-15T12:00:00Z");
vi.setSystemTime(new Date("2025-06-15T12:06:00Z"));
const token = createValidToken("resume-789", createdAt.getTime());
expect(() => verifyToken(token)).toThrow("Token expired");
});
it("throws for a token with a future timestamp (negative age)", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const futureTime = new Date("2025-06-15T13:00:00Z").getTime();
const token = createValidToken("resume-future", futureTime);
expect(() => verifyToken(token)).toThrow("Token expired");
});
it("throws for invalid token format (no dot separator)", () => {
expect(() => verifyToken("nodothere")).toThrow("Invalid token format");
});
it("throws for invalid token format (too many dots)", () => {
expect(() => verifyToken("a.b.c")).toThrow("Invalid token format");
});
it("throws for empty token", () => {
expect(() => verifyToken("")).toThrow("Invalid token format");
});
it("throws for a token with tampered signature", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const token = createValidToken("resume-123", Date.now());
const [payload] = token.split(".");
const tamperedToken = `${payload}.deadbeef0000000000000000000000000000000000000000000000000000dead`;
expect(() => verifyToken(tamperedToken)).toThrow("Invalid token signature");
});
it("throws for a token with tampered payload", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const token = createValidToken("resume-123", Date.now());
const [, signature] = token.split(".");
const tamperedPayload = Buffer.from("hacked-resume:0").toString("base64url");
expect(() => verifyToken(`${tamperedPayload}.${signature}`)).toThrow("Invalid token signature");
});
it("throws for a token with invalid base64 payload (no colon separator)", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const payloadBase64 = Buffer.from("nocolon").toString("base64url");
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
// "nocolon".split(":") => ["nocolon"], so timestampStr is undefined
expect(() => verifyToken(`${payloadBase64}.${signature}`)).toThrow("Invalid token payload");
});
it("throws for payload with non-numeric timestamp", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const payloadBase64 = Buffer.from("resume-id:not-a-number").toString("base64url");
const signature = createHmac("sha256", TEST_SECRET).update(payloadBase64).digest("hex");
expect(() => verifyToken(`${payloadBase64}.${signature}`)).toThrow("Invalid timestamp");
});
it("round-trips: generate then verify returns original resume ID", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const token = generateToken("my-resume-uuid-v7");
const result = verifyToken(token);
expect(result).toBe("my-resume-uuid-v7");
});
it("handles resume IDs with dashes and underscores", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const id = "resume-with-dashes_and_underscores";
const token = generateToken(id);
const result = verifyToken(token);
expect(result).toBe(id);
});
it("resume IDs with colons cause verification to fail", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
// Documents a known limitation: IDs with ":" break the payload format
// because verifyToken splits on ":" to separate ID from timestamp,
// so the second segment becomes part of the ID, not the timestamp.
const token = generateToken("resume:with:colons");
expect(() => verifyToken(token)).toThrow("Invalid timestamp");
});
it("accepts a token at exactly age 0 (just created)", () => {
vi.setSystemTime(new Date("2025-06-15T12:00:00Z"));
const token = createValidToken("fresh", Date.now());
expect(verifyToken(token)).toBe("fresh");
});
});
+3 -3
View File
@@ -1,5 +1,5 @@
import { createIsomorphicFn } from "@tanstack/react-start";
import { createHash, timingSafeEqual } from "node:crypto";
import { createHmac, timingSafeEqual } from "node:crypto";
import { env } from "./env";
@@ -15,7 +15,7 @@ export const generatePrinterToken = createIsomorphicFn().server((resumeId: strin
const payloadBase64 = Buffer.from(payload).toString("base64url");
// Create HMAC signature using AUTH_SECRET
const signature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
const signature = createHmac("sha256", env.AUTH_SECRET).update(payloadBase64).digest("hex");
return `${payloadBase64}.${signature}`;
});
@@ -31,7 +31,7 @@ export const verifyPrinterToken = createIsomorphicFn().server((token: string) =>
const [payloadBase64, signature] = parts;
// Verify signature
const expectedSignature = createHash("sha256").update(`${payloadBase64}.${env.AUTH_SECRET}`).digest("hex");
const expectedSignature = createHmac("sha256", env.AUTH_SECRET).update(payloadBase64).digest("hex");
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
+144
View File
@@ -288,6 +288,150 @@ describe("buildDocument", () => {
expect(doc).toBeInstanceOf(Document);
});
// --- Template configs ---
it("renders with chikorita template (sidebar-only header, solid sidebar bg)", () => {
const data = makeResumeData({
basics: { ...defaultResumeData.basics, name: "Jane", headline: "Dev", email: "j@e.com" },
metadata: {
...defaultResumeData.metadata,
template: "chikorita",
layout: {
sidebarWidth: 35,
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
},
},
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
it("renders with ditgar template (sidebar-only header, tint sidebar bg)", () => {
const data = makeResumeData({
basics: { ...defaultResumeData.basics, name: "Alice", headline: "PM" },
metadata: {
...defaultResumeData.metadata,
template: "ditgar",
layout: {
sidebarWidth: 30,
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
},
},
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
it("renders with pikachu template (main-only header)", () => {
const data = makeResumeData({
basics: { ...defaultResumeData.basics, name: "Bob" },
metadata: {
...defaultResumeData.metadata,
template: "pikachu",
layout: {
sidebarWidth: 35,
pages: [{ fullWidth: false, main: ["experience"], sidebar: ["skills"] }],
},
},
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
it("renders with unknown template (falls back to default config)", () => {
const data = makeResumeData({
metadata: {
...defaultResumeData.metadata,
template: "unknown-template" as any,
},
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
// --- Header edge cases ---
it("renders header with custom fields (with links)", () => {
const data = makeResumeData({
basics: {
...defaultResumeData.basics,
name: "Jane",
customFields: [
{ id: "cf1", icon: "star", text: "Portfolio", link: "https://portfolio.dev" },
{ id: "cf2", icon: "", text: "Plain text field", link: "" },
{ id: "cf3", icon: "", text: "", link: "" }, // empty text — skipped
{ id: "cf4", icon: "", text: "Bad link", link: "javascript:alert(1)" }, // unsafe link — rendered as text
],
},
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
it("renders header without name (no title paragraph)", () => {
const data = makeResumeData({
basics: { ...defaultResumeData.basics, name: "" },
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
it("renders header without headline", () => {
const data = makeResumeData({
basics: { ...defaultResumeData.basics, name: "Jane", headline: "" },
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
// --- Multi-page layout ---
it("renders multi-page layout", () => {
const data = makeResumeData({
metadata: {
...defaultResumeData.metadata,
layout: {
sidebarWidth: 35,
pages: [
{ fullWidth: false, main: ["experience"], sidebar: ["skills"] },
{ fullWidth: true, main: ["education"], sidebar: [] },
],
},
},
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
// --- Section rendering with unknown section ID ---
it("handles unknown section IDs in layout gracefully", () => {
const data = makeResumeData({
metadata: {
...defaultResumeData.metadata,
layout: {
sidebarWidth: 35,
pages: [{ fullWidth: false, main: ["nonexistent-section"], sidebar: [] }],
},
},
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
// --- Free-form page format ---
it("handles free-form page format", () => {
const data = makeResumeData({
metadata: {
...defaultResumeData.metadata,
page: { ...defaultResumeData.metadata.page, format: "free-form" },
},
});
const doc = buildDocument(data);
expect(doc).toBeInstanceOf(Document);
});
it("produces a valid DOCX blob via Packer", async () => {
const { Packer } = await import("docx");
const data = makeResumeData({
+144
View File
@@ -140,4 +140,148 @@ describe("htmlToParagraphs", () => {
const result = htmlToParagraphs("<p><u>under</u></p>");
expect(result).toHaveLength(1);
});
// --- Additional coverage: block elements ---
it("parses blockquote with italic styling", () => {
const result = htmlToParagraphs("<blockquote>Quote text</blockquote>");
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Paragraph);
});
it("parses pre/code block with monospace font", () => {
const result = htmlToParagraphs("<pre>const x = 42;</pre>");
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Paragraph);
});
it("parses hr as empty paragraph", () => {
const result = htmlToParagraphs("<hr>");
expect(result).toHaveLength(1);
});
it("parses heading elements (h1-h6)", () => {
for (let i = 1; i <= 6; i++) {
const result = htmlToParagraphs(`<h${i}>Heading ${i}</h${i}>`);
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Paragraph);
}
});
it("parses div as block element", () => {
const result = htmlToParagraphs("<div>Content in div</div>");
expect(result).toHaveLength(1);
});
it("parses inline code with Courier New font", () => {
const result = htmlToParagraphs("<p><code>inline code</code></p>");
expect(result).toHaveLength(1);
});
it("parses mark/highlight element", () => {
const result = htmlToParagraphs("<p><mark>highlighted</mark></p>");
expect(result).toHaveLength(1);
});
it("parses b tag (same as strong)", () => {
const result = htmlToParagraphs("<p><b>bold</b></p>");
expect(result).toHaveLength(1);
});
it("parses i tag (same as em)", () => {
const result = htmlToParagraphs("<p><i>italic</i></p>");
expect(result).toHaveLength(1);
});
it("parses strike tag", () => {
const result = htmlToParagraphs("<p><strike>struck</strike></p>");
expect(result).toHaveLength(1);
});
// --- Nested lists ---
it("parses nested unordered list", () => {
const html = "<ul><li>Top<ul><li>Nested</li></ul></li></ul>";
const result = htmlToParagraphs(html);
expect(result.length).toBeGreaterThanOrEqual(2);
});
it("parses nested ordered list", () => {
const html = "<ol><li>First<ol><li>Sub</li></ol></li></ol>";
const result = htmlToParagraphs(html);
expect(result.length).toBeGreaterThanOrEqual(2);
});
it("parses list with paragraph inside li", () => {
const html = "<ul><li><p>Paragraph in list</p></li></ul>";
const result = htmlToParagraphs(html);
expect(result.length).toBeGreaterThanOrEqual(1);
});
// --- Style config ---
it("applies styleConfig font and size to text runs", () => {
const result = htmlToParagraphs("<p>Styled</p>", {
font: "Georgia",
size: 24,
color: "333333",
});
expect(result).toHaveLength(1);
});
it("applies linkColor from styleConfig", () => {
const result = htmlToParagraphs('<p><a href="https://example.com">link</a></p>', {
linkColor: "FF0000",
});
expect(result).toHaveLength(1);
const children = getChildren(result[0] as Paragraph);
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
expect(hyperlinks).toHaveLength(1);
});
// --- Top-level text nodes ---
it("handles plain text without wrapper element", () => {
const result = htmlToParagraphs("Just plain text");
expect(result).toHaveLength(1);
});
it("handles link without href falling back to inline text", () => {
const result = htmlToParagraphs("<p><a>No href link</a></p>");
expect(result).toHaveLength(1);
const children = getChildren(result[0] as Paragraph);
// Should have TextRun, not ExternalHyperlink (no href)
const hyperlinks = children.filter((c) => c instanceof ExternalHyperlink);
expect(hyperlinks).toHaveLength(0);
});
it("handles empty blockquote", () => {
const result = htmlToParagraphs("<blockquote></blockquote>");
expect(result).toHaveLength(0);
});
it("handles empty pre", () => {
const result = htmlToParagraphs("<pre></pre>");
expect(result).toHaveLength(0);
});
it("handles empty heading", () => {
const result = htmlToParagraphs("<h1></h1>");
expect(result).toHaveLength(0);
});
// --- Multiple block elements ---
it("handles complex HTML with mixed elements", () => {
const html = `
<h2>Title</h2>
<p>Introduction paragraph</p>
<ul><li>Item 1</li><li>Item 2</li></ul>
<blockquote>A quote</blockquote>
<pre>code block</pre>
`;
const result = htmlToParagraphs(html);
// h2 + p + 2 li + blockquote + pre = 6
expect(result.length).toBeGreaterThanOrEqual(5);
});
});
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vite-plus/test";
import { toSafeDocxLink } from "./link-utils";
describe("toSafeDocxLink", () => {
describe("valid URLs", () => {
it("returns http URLs as-is", () => {
expect(toSafeDocxLink("http://example.com")).toBe("http://example.com/");
});
it("returns https URLs as-is", () => {
expect(toSafeDocxLink("https://example.com")).toBe("https://example.com/");
});
it("returns https URLs with paths", () => {
expect(toSafeDocxLink("https://example.com/path/to/page")).toBe("https://example.com/path/to/page");
});
it("returns https URLs with query parameters", () => {
expect(toSafeDocxLink("https://example.com?foo=bar")).toBe("https://example.com/?foo=bar");
});
it("returns mailto links", () => {
expect(toSafeDocxLink("mailto:user@example.com")).toBe("mailto:user@example.com");
});
it("trims whitespace before processing", () => {
expect(toSafeDocxLink(" https://example.com ")).toBe("https://example.com/");
});
});
describe("invalid/unsafe URLs", () => {
it("returns null for empty string", () => {
expect(toSafeDocxLink("")).toBeNull();
});
it("returns null for whitespace-only string", () => {
expect(toSafeDocxLink(" ")).toBeNull();
});
it("returns null for javascript: URLs", () => {
expect(toSafeDocxLink("javascript:alert(1)")).toBeNull();
});
it("returns null for data: URLs", () => {
expect(toSafeDocxLink("data:text/html,<h1>hi</h1>")).toBeNull();
});
it("returns null for file: URLs", () => {
expect(toSafeDocxLink("file:///etc/passwd")).toBeNull();
});
it("returns null for ftp: URLs", () => {
expect(toSafeDocxLink("ftp://example.com")).toBeNull();
});
it("returns null for invalid URLs", () => {
expect(toSafeDocxLink("not a url at all")).toBeNull();
});
it("returns null for mailto: without email", () => {
expect(toSafeDocxLink("mailto:")).toBeNull();
});
it("returns null for mailto: with only whitespace", () => {
expect(toSafeDocxLink("mailto: ")).toBeNull();
});
});
});
@@ -0,0 +1,654 @@
import { Paragraph } from "docx";
import { beforeEach, describe, expect, it } from "vite-plus/test";
import type { CustomSection, ResumeData } from "@/schema/resume/data";
import { defaultResumeData } from "@/schema/resume/data";
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const COLOR_HEX = "#DC2626";
function freshSections(): ResumeData["sections"] {
return structuredClone(defaultResumeData.sections);
}
beforeEach(() => {
setRenderConfig({
headingFont: "Arial",
headingSizeHalfPt: 28,
bodyFont: "Calibri",
bodySizeHalfPt: 22,
textColorHex: "#000000",
primaryColorHex: "#DC2626",
});
});
// ---------------------------------------------------------------------------
// renderSummary
// ---------------------------------------------------------------------------
describe("renderSummary", () => {
it("renders summary with title and content", () => {
const summary = { title: "Summary", columns: 1, hidden: false, content: "<p>A great developer</p>" };
const result = renderSummary(summary, COLOR_HEX);
expect(result.length).toBeGreaterThan(0);
// First paragraph should be the heading
expect(result[0]).toBeInstanceOf(Paragraph);
});
it("returns empty array when summary is hidden", () => {
const summary = { title: "Summary", columns: 1, hidden: true, content: "<p>Hidden</p>" };
expect(renderSummary(summary, COLOR_HEX)).toHaveLength(0);
});
it("returns empty array when content is empty", () => {
const summary = { title: "Summary", columns: 1, hidden: false, content: "" };
expect(renderSummary(summary, COLOR_HEX)).toHaveLength(0);
});
it("skips heading when title is empty", () => {
const summary = { title: "", columns: 1, hidden: false, content: "<p>Content only</p>" };
const result = renderSummary(summary, COLOR_HEX);
// Should have content paragraphs but no heading
expect(result.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// renderBuiltInSection — experience
// ---------------------------------------------------------------------------
describe("renderBuiltInSection — experience", () => {
it("renders experience items with company and position", () => {
const sections = freshSections();
sections.experience.title = "Experience";
sections.experience.items = [
{
id: "e1",
hidden: false,
company: "Acme Corp",
position: "Engineer",
location: "NYC",
period: "2020 - 2022",
website: { url: "https://acme.com", label: "Acme" },
description: "<p>Built things</p>",
roles: [],
},
];
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
expect(result.length).toBeGreaterThan(1); // heading + item paragraphs
expect(result[0]).toBeInstanceOf(Paragraph);
});
it("returns empty for hidden section", () => {
const sections = freshSections();
sections.experience.hidden = true;
sections.experience.items = [
{
id: "e1",
hidden: false,
company: "Co",
position: "Dev",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
roles: [],
},
];
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
expect(result).toHaveLength(0);
});
it("returns empty when all items are hidden", () => {
const sections = freshSections();
sections.experience.items = [
{
id: "e1",
hidden: true,
company: "Co",
position: "Dev",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
roles: [],
},
];
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
expect(result).toHaveLength(0);
});
it("renders experience with roles", () => {
const sections = freshSections();
sections.experience.title = "Experience";
sections.experience.items = [
{
id: "e1",
hidden: false,
company: "BigCo",
position: "",
location: "SF",
period: "2018 - 2024",
website: { url: "", label: "" },
description: "",
roles: [
{ id: "r1", position: "Senior Dev", period: "2022 - 2024", description: "<p>Led team</p>" },
{ id: "r2", position: "Junior Dev", period: "2018 - 2022", description: "<p>Learned things</p>" },
],
},
];
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
// Should have heading + company title + location + role1 + role1 desc + role2 + role2 desc
expect(result.length).toBeGreaterThanOrEqual(5);
});
it("filters out hidden items", () => {
const sections = freshSections();
sections.experience.title = "Experience";
sections.experience.items = [
{
id: "e1",
hidden: false,
company: "Visible",
position: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
roles: [],
},
{
id: "e2",
hidden: true,
company: "Hidden",
position: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
roles: [],
},
];
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
expect(result.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// renderBuiltInSection — skills
// ---------------------------------------------------------------------------
describe("renderBuiltInSection — skills", () => {
it("renders skills with name, proficiency, and keywords", () => {
const sections = freshSections();
sections.skills.title = "Skills";
sections.skills.items = [
{
id: "s1",
hidden: false,
icon: "",
name: "TypeScript",
proficiency: "Advanced",
level: 4,
keywords: ["frontend", "backend"],
},
];
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
expect(result.length).toBeGreaterThan(1); // heading + skill
});
it("renders skills without proficiency or keywords", () => {
const sections = freshSections();
sections.skills.title = "Skills";
sections.skills.items = [
{ id: "s1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] },
];
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
expect(result.length).toBe(2); // heading + skill name only
});
it("returns empty for empty items", () => {
const sections = freshSections();
const result = renderBuiltInSection("skills", sections.skills, COLOR_HEX);
expect(result).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// renderBuiltInSection — education
// ---------------------------------------------------------------------------
describe("renderBuiltInSection — education", () => {
it("renders education with degree, area, and grade", () => {
const sections = freshSections();
sections.education.title = "Education";
sections.education.items = [
{
id: "ed1",
hidden: false,
school: "MIT",
degree: "BSc",
area: "CS",
grade: "4.0",
location: "Cambridge",
period: "2016 - 2020",
website: { url: "", label: "" },
description: "",
},
];
const result = renderBuiltInSection("education", sections.education, COLOR_HEX);
expect(result.length).toBeGreaterThan(1);
});
it("skips grade paragraph when grade is empty", () => {
const sections = freshSections();
sections.education.title = "Education";
sections.education.items = [
{
id: "ed1",
hidden: false,
school: "MIT",
degree: "BSc",
area: "",
grade: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
];
const result = renderBuiltInSection("education", sections.education, COLOR_HEX);
// heading + title only (no grade, no location, no description, no website)
expect(result).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// renderBuiltInSection — languages
// ---------------------------------------------------------------------------
describe("renderBuiltInSection — languages", () => {
it("renders language with fluency", () => {
const sections = freshSections();
sections.languages.title = "Languages";
sections.languages.items = [{ id: "l1", hidden: false, language: "English", fluency: "Native", level: 5 }];
const result = renderBuiltInSection("languages", sections.languages, COLOR_HEX);
expect(result.length).toBe(2); // heading + language
});
it("renders language without fluency", () => {
const sections = freshSections();
sections.languages.title = "Languages";
sections.languages.items = [{ id: "l1", hidden: false, language: "Spanish", fluency: "", level: 0 }];
const result = renderBuiltInSection("languages", sections.languages, COLOR_HEX);
expect(result.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// renderBuiltInSection — other sections
// ---------------------------------------------------------------------------
describe("renderBuiltInSection — other sections", () => {
it("renders profiles with network and username", () => {
const sections = freshSections();
sections.profiles.title = "Profiles";
sections.profiles.items = [
{
id: "p1",
hidden: false,
icon: "",
network: "GitHub",
username: "janedoe",
website: { url: "https://github.com/janedoe", label: "GitHub" },
},
];
const result = renderBuiltInSection("profiles", sections.profiles, COLOR_HEX);
expect(result.length).toBeGreaterThan(1);
});
it("renders awards with title and awarder", () => {
const sections = freshSections();
sections.awards.title = "Awards";
sections.awards.items = [
{
id: "a1",
hidden: false,
title: "Best Dev",
awarder: "Company",
date: "2024",
website: { url: "", label: "" },
description: "",
},
];
const result = renderBuiltInSection("awards", sections.awards, COLOR_HEX);
expect(result.length).toBeGreaterThan(1);
});
it("renders certifications", () => {
const sections = freshSections();
sections.certifications.title = "Certifications";
sections.certifications.items = [
{
id: "c1",
hidden: false,
title: "AWS",
issuer: "Amazon",
date: "2023",
website: { url: "", label: "" },
description: "",
},
];
const result = renderBuiltInSection("certifications", sections.certifications, COLOR_HEX);
expect(result.length).toBeGreaterThan(1);
});
it("renders publications", () => {
const sections = freshSections();
sections.publications.title = "Publications";
sections.publications.items = [
{
id: "pub1",
hidden: false,
title: "My Paper",
publisher: "IEEE",
date: "2024",
website: { url: "", label: "" },
description: "<p>Research</p>",
},
];
const result = renderBuiltInSection("publications", sections.publications, COLOR_HEX);
expect(result.length).toBeGreaterThan(1);
});
it("renders volunteer section", () => {
const sections = freshSections();
sections.volunteer.title = "Volunteer";
sections.volunteer.items = [
{
id: "v1",
hidden: false,
organization: "Red Cross",
location: "NYC",
period: "2023",
website: { url: "", label: "" },
description: "",
},
];
const result = renderBuiltInSection("volunteer", sections.volunteer, COLOR_HEX);
expect(result.length).toBeGreaterThan(1);
});
it("renders references with position and phone", () => {
const sections = freshSections();
sections.references.title = "References";
sections.references.items = [
{
id: "r1",
hidden: false,
name: "John",
position: "CTO",
phone: "+1234567890",
website: { url: "", label: "" },
description: "<p>Great dev</p>",
},
];
const result = renderBuiltInSection("references", sections.references, COLOR_HEX);
expect(result.length).toBeGreaterThan(1);
});
it("renders interests with keywords", () => {
const sections = freshSections();
sections.interests.title = "Interests";
sections.interests.items = [{ id: "i1", hidden: false, icon: "", name: "Gaming", keywords: ["RPG", "Strategy"] }];
const result = renderBuiltInSection("interests", sections.interests, COLOR_HEX);
expect(result.length).toBe(2);
});
it("renders projects with period and website", () => {
const sections = freshSections();
sections.projects.title = "Projects";
sections.projects.items = [
{
id: "proj1",
hidden: false,
name: "My App",
period: "2024",
website: { url: "https://app.com", label: "App" },
description: "<p>A cool app</p>",
},
];
const result = renderBuiltInSection("projects", sections.projects, COLOR_HEX);
expect(result.length).toBeGreaterThan(2); // heading + title + description + website
});
});
// ---------------------------------------------------------------------------
// renderCustomSection
// ---------------------------------------------------------------------------
describe("renderCustomSection", () => {
it("renders custom experience-typed section", () => {
const custom: CustomSection = {
id: "custom-1",
type: "experience",
title: "Freelance",
columns: 1,
hidden: false,
items: [
{
id: "ci1",
hidden: false,
company: "Client A",
position: "Contractor",
location: "Remote",
period: "2023",
website: { url: "", label: "" },
description: "",
roles: [],
},
],
};
const result = renderCustomSection(custom, COLOR_HEX);
expect(result.length).toBeGreaterThan(0);
});
it("returns empty for hidden custom section", () => {
const custom: CustomSection = {
id: "custom-1",
type: "skills",
title: "Hidden",
columns: 1,
hidden: true,
items: [{ id: "ci1", hidden: false, icon: "", name: "Skill", proficiency: "", level: 0, keywords: [] }],
};
expect(renderCustomSection(custom, COLOR_HEX)).toHaveLength(0);
});
it("returns empty when all items are hidden", () => {
const custom: CustomSection = {
id: "custom-1",
type: "skills",
title: "Skills",
columns: 1,
hidden: false,
items: [{ id: "ci1", hidden: true, icon: "", name: "Skill", proficiency: "", level: 0, keywords: [] }],
};
expect(renderCustomSection(custom, COLOR_HEX)).toHaveLength(0);
});
it("renders summary-type custom section", () => {
const custom: CustomSection = {
id: "custom-1",
type: "summary",
title: "Cover Note",
columns: 1,
hidden: false,
items: [{ id: "ci1", hidden: false, content: "<p>Hello there</p>" }],
};
const result = renderCustomSection(custom, COLOR_HEX);
expect(result.length).toBeGreaterThan(0);
});
it("renders cover-letter-type custom section", () => {
const custom: CustomSection = {
id: "custom-1",
type: "cover-letter",
title: "Cover Letter",
columns: 1,
hidden: false,
items: [
{
id: "ci1",
hidden: false,
recipient: "<p>Dear Hiring Manager</p>",
content: "<p>I am writing to apply...</p>",
},
],
};
const result = renderCustomSection(custom, COLOR_HEX);
expect(result.length).toBeGreaterThan(1); // heading + recipient + content
});
it("renders skills-typed custom section", () => {
const custom: CustomSection = {
id: "custom-1",
type: "skills",
title: "Soft Skills",
columns: 1,
hidden: false,
items: [
{ id: "ci1", hidden: false, icon: "", name: "Leadership", proficiency: "Expert", level: 5, keywords: [] },
],
};
const result = renderCustomSection(custom, COLOR_HEX);
expect(result.length).toBeGreaterThan(1);
});
});
// ---------------------------------------------------------------------------
// setRenderConfig
// ---------------------------------------------------------------------------
describe("setRenderConfig", () => {
it("configures typography that affects all renderers", () => {
setRenderConfig({
headingFont: "Georgia",
headingSizeHalfPt: 32,
bodyFont: "Times New Roman",
bodySizeHalfPt: 24,
textColorHex: "#333333",
primaryColorHex: "#0000FF",
});
const sections = freshSections();
sections.skills.title = "Skills";
sections.skills.items = [
{ id: "s1", hidden: false, icon: "", name: "Test", proficiency: "", level: 0, keywords: [] },
];
const result = renderBuiltInSection("skills", sections.skills, "#0000FF");
// Should not crash and should produce paragraphs
expect(result.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
describe("edge cases", () => {
it("handles website with unsafe URL (no website paragraph added)", () => {
const sections = freshSections();
sections.experience.title = "Experience";
sections.experience.items = [
{
id: "e1",
hidden: false,
company: "Co",
position: "Dev",
location: "",
period: "",
website: { url: "javascript:alert(1)", label: "Evil" },
description: "",
roles: [],
},
];
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
// Should still render without the unsafe website
expect(result.length).toBeGreaterThan(0);
});
it("handles items with only minimal data", () => {
const sections = freshSections();
sections.awards.title = "Awards";
sections.awards.items = [
{
id: "a1",
hidden: false,
title: "Award",
awarder: "",
date: "",
website: { url: "", label: "" },
description: "",
},
];
const result = renderBuiltInSection("awards", sections.awards, COLOR_HEX);
expect(result).toHaveLength(2); // heading + title only
});
it("handles experience with empty description and no location", () => {
const sections = freshSections();
sections.experience.title = "Experience";
sections.experience.items = [
{
id: "e1",
hidden: false,
company: "Co",
position: "Dev",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
roles: [],
},
];
const result = renderBuiltInSection("experience", sections.experience, COLOR_HEX);
// heading + title/subtitle only
expect(result).toHaveLength(2);
});
});
+468
View File
@@ -0,0 +1,468 @@
import { produce } from "immer";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { type ResumeData, defaultResumeData } from "@/schema/resume/data";
import {
addItemToSection,
createCustomSectionWithItem,
createPageWithSection,
getCompatibleMoveTargets,
getSourceSectionTitle,
removeItemFromSource,
} from "./move-item";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// Mock getSectionTitle — it uses @lingui/core/macro `t` which isn't available in tests
vi.mock("./section", () => ({
getSectionTitle: (type: string) => type.charAt(0).toUpperCase() + type.slice(1),
}));
// Mock generateId to return deterministic values
let idCounter = 0;
vi.mock("@/utils/string", () => ({
generateId: () => `mock-id-${++idCounter}`,
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function freshResume(): ResumeData {
return structuredClone(defaultResumeData);
}
function resumeWithSkills(): ResumeData {
const data = freshResume();
data.sections.skills.items = [
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "Advanced", level: 4, keywords: ["frontend"] },
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "Intermediate", level: 3, keywords: [] },
{ id: "s3", hidden: false, icon: "", name: "Rust", proficiency: "Beginner", level: 1, keywords: [] },
];
return data;
}
function resumeWithCustomSections(): ResumeData {
const data = freshResume();
data.customSections = [
{
id: "custom-exp-1",
type: "experience",
title: "Freelance Work",
columns: 1,
hidden: false,
items: [
{
id: "item-1",
hidden: false,
company: "Acme",
position: "Dev",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
roles: [],
},
],
},
{
id: "custom-skills-1",
type: "skills",
title: "Soft Skills",
columns: 1,
hidden: false,
items: [{ id: "cs1", hidden: false, icon: "", name: "Leadership", proficiency: "", level: 0, keywords: [] }],
},
];
// Add custom sections to the layout
data.metadata.layout.pages[0].main.push("custom-exp-1");
data.metadata.layout.pages[0].sidebar.push("custom-skills-1");
return data;
}
function resumeWithMultiplePages(): ResumeData {
const data = resumeWithCustomSections();
data.metadata.layout.pages.push({
fullWidth: false,
main: ["experience"],
sidebar: ["skills"],
});
return data;
}
beforeEach(() => {
idCounter = 0;
});
// ---------------------------------------------------------------------------
// getSourceSectionTitle
// ---------------------------------------------------------------------------
describe("getSourceSectionTitle", () => {
it("returns the default section title for a standard section", () => {
const data = freshResume();
const title = getSourceSectionTitle(data, "experience");
expect(title).toBe("Experience");
});
it("returns the custom section title when customSectionId is provided", () => {
const data = resumeWithCustomSections();
const title = getSourceSectionTitle(data, "experience", "custom-exp-1");
expect(title).toBe("Freelance Work");
});
it("falls back to default title when customSectionId doesn't match", () => {
const data = resumeWithCustomSections();
const title = getSourceSectionTitle(data, "experience", "nonexistent-id");
expect(title).toBe("Experience");
});
it("returns the default title for various section types", () => {
const data = freshResume();
expect(getSourceSectionTitle(data, "skills")).toBe("Skills");
expect(getSourceSectionTitle(data, "education")).toBe("Education");
expect(getSourceSectionTitle(data, "projects")).toBe("Projects");
});
});
// ---------------------------------------------------------------------------
// getCompatibleMoveTargets
// ---------------------------------------------------------------------------
describe("getCompatibleMoveTargets", () => {
it("returns empty sections for each page when no compatible targets exist", () => {
const data = freshResume();
const targets = getCompatibleMoveTargets(data, "skills", undefined);
// The source section (skills) itself should be excluded
// All other sections have different types, so no compatible targets
expect(targets).toHaveLength(1);
expect(targets[0].sections).toHaveLength(0);
});
it("finds custom sections with matching type as move targets", () => {
const data = resumeWithCustomSections();
// Source is the standard "skills" section
const targets = getCompatibleMoveTargets(data, "skills", undefined);
const allSections = targets.flatMap((p) => p.sections);
expect(allSections.some((s) => s.sectionId === "custom-skills-1")).toBe(true);
});
it("excludes the source section from targets", () => {
const data = resumeWithCustomSections();
// Source is the custom skills section
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
const allSections = targets.flatMap((p) => p.sections);
expect(allSections.some((s) => s.sectionId === "custom-skills-1")).toBe(false);
// But standard "skills" should be included
expect(allSections.some((s) => s.sectionId === "skills")).toBe(true);
});
it("excludes the standard source section when sourceSectionId is undefined", () => {
const data = resumeWithCustomSections();
const targets = getCompatibleMoveTargets(data, "experience", undefined);
const allSections = targets.flatMap((p) => p.sections);
// Standard "experience" should be excluded (it's the source)
expect(allSections.some((s) => s.sectionId === "experience")).toBe(false);
// Custom experience section should be included
expect(allSections.some((s) => s.sectionId === "custom-exp-1")).toBe(true);
});
it("returns targets across multiple pages", () => {
const data = resumeWithMultiplePages();
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
expect(targets).toHaveLength(2);
// Page 0 should have standard skills in sidebar
const page0Sections = targets[0].sections;
expect(page0Sections.some((s) => s.sectionId === "skills")).toBe(true);
// Page 1 should also have skills in sidebar
const page1Sections = targets[1].sections;
expect(page1Sections.some((s) => s.sectionId === "skills")).toBe(true);
});
it("correctly labels standard vs custom sections", () => {
const data = resumeWithCustomSections();
const targets = getCompatibleMoveTargets(data, "skills", "custom-skills-1");
const allSections = targets.flatMap((p) => p.sections);
const standardSection = allSections.find((s) => s.sectionId === "skills");
expect(standardSection?.isStandard).toBe(true);
});
it("correctly labels custom sections", () => {
const data = resumeWithCustomSections();
const targets = getCompatibleMoveTargets(data, "skills", undefined);
const allSections = targets.flatMap((p) => p.sections);
const customSection = allSections.find((s) => s.sectionId === "custom-skills-1");
expect(customSection?.isStandard).toBe(false);
expect(customSection?.sectionTitle).toBe("Soft Skills");
});
it("includes page index in results", () => {
const data = resumeWithMultiplePages();
const targets = getCompatibleMoveTargets(data, "experience", undefined);
expect(targets[0].pageIndex).toBe(0);
expect(targets[1].pageIndex).toBe(1);
});
it("returns no targets for a section type not present elsewhere", () => {
const data = freshResume();
const targets = getCompatibleMoveTargets(data, "awards", undefined);
// awards only appears once in the layout
const allSections = targets.flatMap((p) => p.sections);
expect(allSections).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// removeItemFromSource
// ---------------------------------------------------------------------------
describe("removeItemFromSource", () => {
it("removes an item from a standard section and returns it", () => {
const data = resumeWithSkills();
const result = produce(data, (draft) => {
const removed = removeItemFromSource(draft, "s2", "skills");
expect(removed).not.toBeNull();
expect(removed!.id).toBe("s2");
});
expect(result.sections.skills.items).toHaveLength(2);
expect(result.sections.skills.items.every((i) => i.id !== "s2")).toBe(true);
});
it("removes the first item from a standard section", () => {
const data = resumeWithSkills();
const result = produce(data, (draft) => {
const removed = removeItemFromSource(draft, "s1", "skills");
expect(removed!.id).toBe("s1");
});
expect(result.sections.skills.items).toHaveLength(2);
expect(result.sections.skills.items[0].id).toBe("s2");
});
it("removes the last item from a standard section", () => {
const data = resumeWithSkills();
const result = produce(data, (draft) => {
const removed = removeItemFromSource(draft, "s3", "skills");
expect(removed!.id).toBe("s3");
});
expect(result.sections.skills.items).toHaveLength(2);
});
it("returns null when item is not found in standard section", () => {
const data = resumeWithSkills();
produce(data, (draft) => {
const removed = removeItemFromSource(draft, "nonexistent", "skills");
expect(removed).toBeNull();
});
});
it("removes an item from a custom section", () => {
const data = resumeWithCustomSections();
const result = produce(data, (draft) => {
const removed = removeItemFromSource(draft, "item-1", "experience", "custom-exp-1");
expect(removed).not.toBeNull();
expect(removed!.id).toBe("item-1");
});
const customSection = result.customSections.find((s) => s.id === "custom-exp-1")!;
expect(customSection.items).toHaveLength(0);
});
it("returns null when custom section is not found", () => {
const data = resumeWithCustomSections();
produce(data, (draft) => {
const removed = removeItemFromSource(draft, "item-1", "experience", "nonexistent");
expect(removed).toBeNull();
});
});
it("returns null when item is not in the custom section", () => {
const data = resumeWithCustomSections();
produce(data, (draft) => {
const removed = removeItemFromSource(draft, "nonexistent", "experience", "custom-exp-1");
expect(removed).toBeNull();
});
});
});
// ---------------------------------------------------------------------------
// addItemToSection
// ---------------------------------------------------------------------------
describe("addItemToSection", () => {
it("adds an item to a standard section", () => {
const data = freshResume();
const newItem = { id: "new-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
addItemToSection(draft, newItem, "skills", "skills");
});
expect(result.sections.skills.items).toHaveLength(1);
expect(result.sections.skills.items[0].id).toBe("new-1");
});
it("adds an item to a custom section", () => {
const data = resumeWithCustomSections();
const newItem = { id: "new-2", hidden: false, icon: "", name: "Teamwork", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
addItemToSection(draft, newItem, "custom-skills-1", "skills");
});
const customSection = result.customSections.find((s) => s.id === "custom-skills-1")!;
expect(customSection.items).toHaveLength(2);
});
it("does nothing when target custom section doesn't exist", () => {
const data = freshResume();
const newItem = { id: "new-3", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
addItemToSection(draft, newItem, "nonexistent-section", "skills");
});
// Nothing should change
expect(result.sections.skills.items).toHaveLength(0);
});
it("appends to the end of an existing items array", () => {
const data = resumeWithSkills();
const newItem = { id: "s4", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
addItemToSection(draft, newItem, "skills", "skills");
});
expect(result.sections.skills.items).toHaveLength(4);
expect(result.sections.skills.items[3].id).toBe("s4");
});
});
// ---------------------------------------------------------------------------
// createCustomSectionWithItem
// ---------------------------------------------------------------------------
describe("createCustomSectionWithItem", () => {
it("creates a new custom section with the given item", () => {
const data = freshResume();
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
const sectionId = createCustomSectionWithItem(draft, item, "skills", "Tech Skills", 0);
expect(sectionId).toBe("mock-id-1");
});
expect(result.customSections).toHaveLength(1);
expect(result.customSections[0].title).toBe("Tech Skills");
expect(result.customSections[0].type).toBe("skills");
expect(result.customSections[0].items).toHaveLength(1);
expect(result.customSections[0].columns).toBe(1);
expect(result.customSections[0].hidden).toBe(false);
});
it("adds the new section to the target page's main column", () => {
const data = freshResume();
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
const mainLengthBefore = data.metadata.layout.pages[0].main.length;
const result = produce(data, (draft) => {
createCustomSectionWithItem(draft, item, "skills", "Skills 2", 0);
});
expect(result.metadata.layout.pages[0].main).toHaveLength(mainLengthBefore + 1);
expect(result.metadata.layout.pages[0].main).toContain("mock-id-1");
});
it("handles targeting a specific page index", () => {
const data = resumeWithMultiplePages();
const item = { id: "item-2", hidden: false, icon: "", name: "Python", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
createCustomSectionWithItem(draft, item, "skills", "More Skills", 1);
});
expect(result.metadata.layout.pages[1].main).toContain("mock-id-1");
// Page 0 should be unchanged
expect(result.metadata.layout.pages[0].main).not.toContain("mock-id-1");
});
it("handles out-of-bounds page index gracefully (no crash)", () => {
const data = freshResume();
const item = { id: "item-3", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
// Should not throw — the page just doesn't exist so nothing is pushed
const result = produce(data, (draft) => {
createCustomSectionWithItem(draft, item, "skills", "Skills", 99);
});
// Custom section still gets created
expect(result.customSections).toHaveLength(1);
// But no page was modified
expect(result.metadata.layout.pages[0].main).not.toContain("mock-id-1");
});
});
// ---------------------------------------------------------------------------
// createPageWithSection
// ---------------------------------------------------------------------------
describe("createPageWithSection", () => {
it("creates a new page with a custom section", () => {
const data = freshResume();
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
const pagesBefore = data.metadata.layout.pages.length;
const result = produce(data, (draft) => {
createPageWithSection(draft, item, "skills", "New Skills Page");
});
expect(result.metadata.layout.pages).toHaveLength(pagesBefore + 1);
const newPage = result.metadata.layout.pages[result.metadata.layout.pages.length - 1];
expect(newPage.fullWidth).toBe(false);
expect(newPage.main).toContain("mock-id-1");
expect(newPage.sidebar).toHaveLength(0);
});
it("creates the custom section with correct properties", () => {
const data = freshResume();
const item = { id: "item-1", hidden: false, icon: "", name: "Go", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
createPageWithSection(draft, item, "skills", "Page 2 Skills");
});
expect(result.customSections).toHaveLength(1);
expect(result.customSections[0].title).toBe("Page 2 Skills");
expect(result.customSections[0].type).toBe("skills");
expect(result.customSections[0].items).toHaveLength(1);
});
it("can create multiple pages in sequence", () => {
const data = freshResume();
const item1 = { id: "i1", hidden: false, icon: "", name: "A", proficiency: "", level: 0, keywords: [] };
const item2 = { id: "i2", hidden: false, icon: "", name: "B", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
createPageWithSection(draft, item1, "skills", "Page 2");
createPageWithSection(draft, item2, "skills", "Page 3");
});
expect(result.metadata.layout.pages).toHaveLength(3);
expect(result.customSections).toHaveLength(2);
});
});
+469
View File
@@ -0,0 +1,469 @@
import { describe, expect, it } from "vite-plus/test";
import { type ResumeData, defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
import { ResumePatchError, applyResumePatches, jsonPatchOperationSchema } from "./patch";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Returns a deep clone of defaultResumeData to avoid cross-test mutation. */
function freshResume(): ResumeData {
return structuredClone(defaultResumeData);
}
// ---------------------------------------------------------------------------
// jsonPatchOperationSchema
// ---------------------------------------------------------------------------
describe("jsonPatchOperationSchema", () => {
it("accepts a valid 'add' operation", () => {
const op = { op: "add", path: "/basics/name", value: "Jane" };
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
});
it("accepts a valid 'remove' operation", () => {
const op = { op: "remove", path: "/basics/name" };
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
});
it("accepts a valid 'replace' operation", () => {
const op = { op: "replace", path: "/basics/name", value: "Jane" };
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
});
it("accepts a valid 'move' operation", () => {
const op = { op: "move", path: "/basics/name", from: "/basics/headline" };
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
});
it("accepts a valid 'copy' operation", () => {
const op = { op: "copy", path: "/basics/headline", from: "/basics/name" };
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
});
it("accepts a valid 'test' operation", () => {
const op = { op: "test", path: "/basics/name", value: "" };
expect(jsonPatchOperationSchema.parse(op)).toEqual(op);
});
it("rejects an unknown op type", () => {
expect(() => jsonPatchOperationSchema.parse({ op: "patch", path: "/foo" })).toThrow();
});
it("rejects 'move' without from", () => {
expect(() => jsonPatchOperationSchema.parse({ op: "move", path: "/foo" })).toThrow();
});
it("rejects 'copy' without from", () => {
expect(() => jsonPatchOperationSchema.parse({ op: "copy", path: "/foo" })).toThrow();
});
it("accepts 'add' with undefined value (z.any() allows it)", () => {
// z.any() accepts undefined — this is valid at the schema level
const result = jsonPatchOperationSchema.parse({ op: "add", path: "/foo" });
expect(result.op).toBe("add");
});
it("accepts 'replace' with explicit value of null", () => {
const result = jsonPatchOperationSchema.parse({ op: "replace", path: "/foo", value: null });
expect(result.op).toBe("replace");
});
it("accepts 'test' with value of 0", () => {
const result = jsonPatchOperationSchema.parse({ op: "test", path: "/foo", value: 0 });
expect(result.op).toBe("test");
});
it("rejects operations without a path", () => {
expect(() => jsonPatchOperationSchema.parse({ op: "remove" })).toThrow();
});
});
// ---------------------------------------------------------------------------
// ResumePatchError
// ---------------------------------------------------------------------------
describe("ResumePatchError", () => {
it("stores code, message, index, and operation", () => {
const op = { op: "replace" as const, path: "/basics/name", value: "Jane" };
const err = new ResumePatchError("TEST_OPERATION_FAILED", "test failed", 2, op);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("ResumePatchError");
expect(err.code).toBe("TEST_OPERATION_FAILED");
expect(err.message).toBe("test failed");
expect(err.index).toBe(2);
expect(err.operation).toEqual(op);
});
it("has a proper prototype chain", () => {
const err = new ResumePatchError("CODE", "msg", 0, { op: "remove", path: "/x" });
expect(err instanceof ResumePatchError).toBe(true);
expect(err instanceof Error).toBe(true);
});
});
// ---------------------------------------------------------------------------
// applyResumePatches — success cases
// ---------------------------------------------------------------------------
describe("applyResumePatches", () => {
describe("basic operations", () => {
it("replaces a top-level string field", () => {
const data = freshResume();
const result = applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Alice" }]);
expect(result.basics.name).toBe("Alice");
});
it("replaces a nested field", () => {
const data = freshResume();
const result = applyResumePatches(data, [
{ op: "replace", path: "/basics/website/url", value: "https://example.com" },
]);
expect(result.basics.website.url).toBe("https://example.com");
});
it("adds an item to a section's items array", () => {
const data = freshResume();
const newSkill = {
id: "skill-1",
hidden: false,
icon: "",
name: "TypeScript",
proficiency: "Advanced",
level: 4,
keywords: ["frontend"],
};
const result = applyResumePatches(data, [{ op: "add", path: "/sections/skills/items/-", value: newSkill }]);
expect(result.sections.skills.items).toHaveLength(1);
expect(result.sections.skills.items[0].name).toBe("TypeScript");
});
it("removes an item from a section's items array", () => {
const data = freshResume();
data.sections.skills.items = [
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "", level: 0, keywords: [] },
];
const result = applyResumePatches(data, [{ op: "remove", path: "/sections/skills/items/0" }]);
expect(result.sections.skills.items).toHaveLength(1);
expect(result.sections.skills.items[0].name).toBe("TS");
});
it("applies multiple operations in sequence", () => {
const data = freshResume();
const result = applyResumePatches(data, [
{ op: "replace", path: "/basics/name", value: "Bob" },
{ op: "replace", path: "/basics/email", value: "bob@test.com" },
{ op: "replace", path: "/basics/headline", value: "Developer" },
]);
expect(result.basics.name).toBe("Bob");
expect(result.basics.email).toBe("bob@test.com");
expect(result.basics.headline).toBe("Developer");
});
it("does not mutate the original data", () => {
const data = freshResume();
const originalName = data.basics.name;
applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Mutated?" }]);
expect(data.basics.name).toBe(originalName);
});
});
describe("test operation", () => {
it("succeeds when test value matches", () => {
const data = freshResume();
data.basics.name = "Alice";
const result = applyResumePatches(data, [
{ op: "test", path: "/basics/name", value: "Alice" },
{ op: "replace", path: "/basics/name", value: "Bob" },
]);
expect(result.basics.name).toBe("Bob");
});
it("throws ResumePatchError when test value does not match", () => {
const data = freshResume();
data.basics.name = "Alice";
expect(() => applyResumePatches(data, [{ op: "test", path: "/basics/name", value: "Wrong" }])).toThrow(
ResumePatchError,
);
});
});
describe("copy and move operations", () => {
it("copies a value from one path to another", () => {
const data = freshResume();
data.basics.name = "Alice";
const result = applyResumePatches(data, [{ op: "copy", path: "/basics/headline", from: "/basics/name" }]);
expect(result.basics.headline).toBe("Alice");
expect(result.basics.name).toBe("Alice");
});
it("moves a value between compatible paths", () => {
const data = freshResume();
data.basics.name = "Alice";
// Move name to headline — both are strings, but move deletes the source.
// Since the schema requires name to be a string, we replace it back after moving.
const result = applyResumePatches(data, [
{ op: "move", path: "/basics/headline", from: "/basics/name" },
{ op: "add", path: "/basics/name", value: "" },
]);
expect(result.basics.headline).toBe("Alice");
expect(result.basics.name).toBe("");
});
});
describe("metadata operations", () => {
it("replaces the template", () => {
const data = freshResume();
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/template", value: "pikachu" }]);
expect(result.metadata.template).toBe("pikachu");
});
it("replaces design colors", () => {
const data = freshResume();
const result = applyResumePatches(data, [
{ op: "replace", path: "/metadata/design/colors/primary", value: "rgba(0, 0, 255, 1)" },
]);
expect(result.metadata.design.colors.primary).toBe("rgba(0, 0, 255, 1)");
});
it("toggles section visibility", () => {
const data = freshResume();
const result = applyResumePatches(data, [{ op: "replace", path: "/sections/skills/hidden", value: true }]);
expect(result.sections.skills.hidden).toBe(true);
});
it("replaces page margins", () => {
const data = freshResume();
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/page/marginX", value: 20 }]);
expect(result.metadata.page.marginX).toBe(20);
});
});
describe("custom sections", () => {
it("adds a custom section", () => {
const data = freshResume();
const customSection = {
id: "custom-1",
type: "experience",
title: "Freelance",
columns: 1,
hidden: false,
items: [],
};
const result = applyResumePatches(data, [{ op: "add", path: "/customSections/-", value: customSection }]);
expect(result.customSections).toHaveLength(1);
expect(result.customSections[0].title).toBe("Freelance");
});
it("removes a custom section by index", () => {
const data = freshResume();
data.customSections = [
{ id: "c1", type: "experience", title: "Freelance", columns: 1, hidden: false, items: [] },
{ id: "c2", type: "projects", title: "Side Projects", columns: 1, hidden: false, items: [] },
];
const result = applyResumePatches(data, [{ op: "remove", path: "/customSections/0" }]);
expect(result.customSections).toHaveLength(1);
expect(result.customSections[0].id).toBe("c2");
});
});
describe("picture operations", () => {
it("replaces picture URL", () => {
const data = freshResume();
const result = applyResumePatches(data, [
{ op: "replace", path: "/picture/url", value: "https://example.com/photo.jpg" },
]);
expect(result.picture.url).toBe("https://example.com/photo.jpg");
});
it("toggles picture visibility", () => {
const data = freshResume();
const result = applyResumePatches(data, [{ op: "replace", path: "/picture/hidden", value: true }]);
expect(result.picture.hidden).toBe(true);
});
it("updates picture size within valid range", () => {
const data = freshResume();
const result = applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: 120 }]);
expect(result.picture.size).toBe(120);
});
});
// ---------------------------------------------------------------------------
// applyResumePatches — error / edge cases
// ---------------------------------------------------------------------------
describe("error handling", () => {
it("throws ResumePatchError for invalid path", () => {
const data = freshResume();
expect(() => applyResumePatches(data, [{ op: "replace", path: "/nonexistent/path", value: "x" }])).toThrow(
ResumePatchError,
);
});
it("throws ResumePatchError for unresolvable 'from' in move", () => {
const data = freshResume();
expect(() =>
applyResumePatches(data, [{ op: "move", path: "/basics/name", from: "/nonexistent/field" }]),
).toThrow(ResumePatchError);
});
it("throws ResumePatchError for out-of-bounds array index", () => {
const data = freshResume();
expect(() => applyResumePatches(data, [{ op: "remove", path: "/sections/skills/items/999" }])).toThrow(
ResumePatchError,
);
});
it("throws when patch produces invalid resume data (e.g. wrong type)", () => {
const data = freshResume();
// Setting a boolean field to a string should fail schema validation
expect(() =>
applyResumePatches(data, [{ op: "replace", path: "/picture/hidden", value: "not-a-boolean" }]),
).toThrow();
});
it("throws when patch produces invalid resume data (picture size out of range)", () => {
const data = freshResume();
// picture.size must be 32-512
expect(() => applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: 9999 }])).toThrow();
});
it("handles empty operations array without error", () => {
const data = freshResume();
const result = applyResumePatches(data, []);
// Should return equivalent data
expect(result.basics.name).toBe(data.basics.name);
});
it("returns a valid ResumeData after patching", () => {
const data = freshResume();
const result = applyResumePatches(data, [{ op: "replace", path: "/basics/name", value: "Valid" }]);
expect(resumeDataSchema.safeParse(result).success).toBe(true);
});
});
describe("layout operations", () => {
it("replaces sidebar width", () => {
const data = freshResume();
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/layout/sidebarWidth", value: 40 }]);
expect(result.metadata.layout.sidebarWidth).toBe(40);
});
it("adds a section to a page layout", () => {
const data = freshResume();
const result = applyResumePatches(data, [
{ op: "add", path: "/metadata/layout/pages/0/sidebar/-", value: "custom-section-id" },
]);
expect(result.metadata.layout.pages[0].sidebar).toContain("custom-section-id");
});
it("adds a new page to layout", () => {
const data = freshResume();
const newPage = { fullWidth: false, main: [], sidebar: [] };
const result = applyResumePatches(data, [{ op: "add", path: "/metadata/layout/pages/-", value: newPage }]);
expect(result.metadata.layout.pages).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// Additional branch/edge case coverage
// ---------------------------------------------------------------------------
describe("test operation failures (applyPatch catch block)", () => {
it("throws ResumePatchError when test op value mismatches (triggers catch)", () => {
const data = freshResume();
data.basics.name = "Real Name";
try {
applyResumePatches(data, [{ op: "test", path: "/basics/name", value: "Wrong Name" }]);
expect.unreachable("Should have thrown");
} catch (error) {
expect(error).toBeInstanceOf(ResumePatchError);
const patchErr = error as ResumePatchError;
expect(patchErr.code).toBeTruthy();
expect(patchErr.index).toBeDefined();
expect(patchErr.operation).toBeDefined();
}
});
it("ResumePatchError has correct operation in error", () => {
const data = freshResume();
data.basics.name = "Alice";
const testOp = { op: "test" as const, path: "/basics/name", value: "Bob" };
try {
applyResumePatches(data, [testOp]);
expect.unreachable("Should have thrown");
} catch (error) {
const patchErr = error as ResumePatchError;
expect(patchErr.operation.path).toBe("/basics/name");
}
});
});
describe("schema validation after patch", () => {
it("throws on negative picture size (below min)", () => {
const data = freshResume();
expect(() => applyResumePatches(data, [{ op: "replace", path: "/picture/size", value: -1 }])).toThrow();
});
it("falls back to default for invalid page format (schema uses .catch)", () => {
const data = freshResume();
// format has .catch("a4"), so invalid values fall back rather than throwing
const result = applyResumePatches(data, [{ op: "replace", path: "/metadata/page/format", value: "tabloid" }]);
expect(result.metadata.page.format).toBe("a4");
});
it("allows setting customSections to empty array", () => {
const data = freshResume();
data.customSections = [{ id: "c1", type: "experience", title: "X", columns: 1, hidden: false, items: [] }];
const result = applyResumePatches(data, [{ op: "replace", path: "/customSections", value: [] }]);
expect(result.customSections).toHaveLength(0);
});
});
describe("replace at array index", () => {
it("replaces a specific item in an array", () => {
const data = freshResume();
data.sections.skills.items = [
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
{ id: "s2", hidden: false, icon: "", name: "TS", proficiency: "", level: 0, keywords: [] },
];
const result = applyResumePatches(data, [
{ op: "replace", path: "/sections/skills/items/0/name", value: "JavaScript" },
]);
expect(result.sections.skills.items[0].name).toBe("JavaScript");
expect(result.sections.skills.items[1].name).toBe("TS");
});
});
});
+358
View File
@@ -0,0 +1,358 @@
import { produce } from "immer";
import { describe, expect, it } from "vite-plus/test";
import type { SectionItem } from "@/schema/resume/data";
import { type ResumeData, defaultResumeData } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "./section-actions";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function freshResume(): ResumeData {
return structuredClone(defaultResumeData);
}
function resumeWithExperience(): ResumeData {
const data = freshResume();
data.sections.experience.items = [
{
id: "exp-1",
hidden: false,
company: "Acme Corp",
position: "Engineer",
location: "NYC",
period: "2020-2022",
website: { url: "", label: "" },
description: "<p>Built things</p>",
roles: [],
},
{
id: "exp-2",
hidden: false,
company: "Beta Inc",
position: "Senior Dev",
location: "SF",
period: "2022-2024",
website: { url: "", label: "" },
description: "<p>Led things</p>",
roles: [],
},
];
return data;
}
function resumeWithCustomSection(): ResumeData {
const data = freshResume();
data.customSections = [
{
id: "custom-1",
type: "skills",
title: "Soft Skills",
columns: 1,
hidden: false,
items: [{ id: "cs-1", hidden: false, icon: "", name: "Leadership", proficiency: "", level: 0, keywords: [] }],
},
];
return data;
}
// ---------------------------------------------------------------------------
// createSectionItem
// ---------------------------------------------------------------------------
describe("createSectionItem", () => {
it("adds an item to an empty standard section", () => {
const data = freshResume();
const newItem = {
id: "new-1",
hidden: false,
icon: "",
name: "TypeScript",
proficiency: "Advanced",
level: 4,
keywords: [],
};
const result = produce(data, (draft) => {
createSectionItem(draft, "skills", newItem);
});
expect(result.sections.skills.items).toHaveLength(1);
expect(result.sections.skills.items[0]).toEqual(newItem);
});
it("appends an item to a non-empty standard section", () => {
const data = resumeWithExperience();
const newExp = {
id: "exp-3",
hidden: false,
company: "Gamma LLC",
position: "CTO",
location: "",
period: "2024-",
website: { url: "", label: "" },
description: "",
roles: [],
};
const result = produce(data, (draft) => {
createSectionItem(draft, "experience", newExp);
});
expect(result.sections.experience.items).toHaveLength(3);
expect(result.sections.experience.items[2].id).toBe("exp-3");
});
it("adds an item to a custom section", () => {
const data = resumeWithCustomSection();
const newItem = { id: "cs-2", hidden: false, icon: "", name: "Teamwork", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
createSectionItem(draft, "skills", newItem, "custom-1");
});
const custom = result.customSections.find((s) => s.id === "custom-1")!;
expect(custom.items).toHaveLength(2);
});
it("does nothing when custom section id doesn't exist", () => {
const data = resumeWithCustomSection();
const newItem = { id: "cs-3", hidden: false, icon: "", name: "X", proficiency: "", level: 0, keywords: [] };
const result = produce(data, (draft) => {
createSectionItem(draft, "skills", newItem, "nonexistent");
});
// Nothing should have changed
expect(result.customSections[0].items).toHaveLength(1);
expect(result.sections.skills.items).toHaveLength(0);
});
it("can add multiple items in sequence", () => {
const data = freshResume();
const result = produce(data, (draft) => {
createSectionItem(draft, "skills", {
id: "a",
hidden: false,
icon: "",
name: "A",
proficiency: "",
level: 0,
keywords: [],
});
createSectionItem(draft, "skills", {
id: "b",
hidden: false,
icon: "",
name: "B",
proficiency: "",
level: 0,
keywords: [],
});
createSectionItem(draft, "skills", {
id: "c",
hidden: false,
icon: "",
name: "C",
proficiency: "",
level: 0,
keywords: [],
});
});
expect(result.sections.skills.items).toHaveLength(3);
expect(result.sections.skills.items.map((i) => i.id)).toEqual(["a", "b", "c"]);
});
it("adds items to different section types", () => {
const data = freshResume();
const result = produce(data, (draft) => {
createSectionItem(draft, "awards", {
id: "a1",
hidden: false,
title: "Best Dev",
awarder: "Company",
date: "2024",
website: { url: "", label: "" },
description: "",
});
createSectionItem(draft, "education", {
id: "e1",
hidden: false,
school: "MIT",
degree: "BS",
area: "CS",
grade: "",
location: "",
period: "2016-2020",
website: { url: "", label: "" },
description: "",
});
});
expect(result.sections.awards.items).toHaveLength(1);
expect(result.sections.education.items).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// updateSectionItem
// ---------------------------------------------------------------------------
describe("updateSectionItem", () => {
it("updates an existing item in a standard section", () => {
const data = resumeWithExperience();
const result = produce(data, (draft) => {
updateSectionItem(draft, "experience", {
id: "exp-1",
hidden: false,
company: "Acme Corp Updated",
position: "Senior Engineer",
location: "NYC",
period: "2020-2023",
website: { url: "", label: "" },
description: "<p>Built more things</p>",
roles: [],
});
});
expect(result.sections.experience.items[0].company).toBe("Acme Corp Updated");
expect(result.sections.experience.items[0].position).toBe("Senior Engineer");
// Second item should be unchanged
expect(result.sections.experience.items[1].company).toBe("Beta Inc");
});
it("updates the last item in a section", () => {
const data = resumeWithExperience();
const result = produce(data, (draft) => {
updateSectionItem(draft, "experience", {
id: "exp-2",
hidden: true,
company: "Beta Inc",
position: "VP",
location: "Remote",
period: "2022-2024",
website: { url: "https://beta.com", label: "Beta" },
description: "",
roles: [],
});
});
expect(result.sections.experience.items[1].hidden).toBe(true);
expect(result.sections.experience.items[1].position).toBe("VP");
});
it("does nothing when item id doesn't match any item in standard section", () => {
const data = resumeWithExperience();
const result = produce(data, (draft) => {
updateSectionItem(draft, "experience", {
id: "nonexistent",
hidden: false,
company: "Ghost",
position: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
roles: [],
});
});
// Nothing should change
expect(result.sections.experience.items).toHaveLength(2);
expect(result.sections.experience.items[0].company).toBe("Acme Corp");
});
it("updates an item in a custom section", () => {
const data = resumeWithCustomSection();
const result = produce(data, (draft) => {
updateSectionItem(
draft,
"skills",
{
id: "cs-1",
hidden: false,
icon: "star",
name: "Communication",
proficiency: "Expert",
level: 5,
keywords: ["soft"],
},
"custom-1",
);
});
const section = result.customSections.find((s) => s.id === "custom-1")!;
const item = section.items[0] as SectionItem<"skills">;
expect(item.name).toBe("Communication");
});
it("does nothing when custom section id doesn't exist", () => {
const data = resumeWithCustomSection();
const result = produce(data, (draft) => {
updateSectionItem(
draft,
"skills",
{ id: "cs-1", hidden: false, icon: "", name: "Updated", proficiency: "", level: 0, keywords: [] },
"nonexistent",
);
});
const section = result.customSections.find((s) => s.id === "custom-1")!;
const item = section.items[0] as SectionItem<"skills">;
expect(item.name).toBe("Leadership");
});
it("does nothing when item id doesn't match in custom section", () => {
const data = resumeWithCustomSection();
const result = produce(data, (draft) => {
updateSectionItem(
draft,
"skills",
{ id: "nonexistent", hidden: false, icon: "", name: "X", proficiency: "", level: 0, keywords: [] },
"custom-1",
);
});
const section = result.customSections.find((s) => s.id === "custom-1")!;
const item = section.items[0] as SectionItem<"skills">;
expect(section.items).toHaveLength(1);
expect(item.name).toBe("Leadership");
});
it("replaces the entire item object (not a merge)", () => {
const data = resumeWithExperience();
const result = produce(data, (draft) => {
updateSectionItem(draft, "experience", {
id: "exp-1",
hidden: false,
company: "New Company",
position: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
roles: [],
});
});
// position should be empty now — it's a full replace, not merge
expect(result.sections.experience.items[0].position).toBe("");
expect(result.sections.experience.items[0].company).toBe("New Company");
});
});
+43
View File
@@ -0,0 +1,43 @@
import type { WritableDraft } from "immer";
import type { ResumeData, SectionType } from "@/schema/resume/data";
/**
* Pushes a new item into a section's items array.
* Handles both built-in sections and custom sections.
*/
export function createSectionItem(
draft: WritableDraft<ResumeData>,
sectionKey: SectionType,
formData: Record<string, unknown>,
customSectionId?: string,
) {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (section) section.items.push(formData as never);
} else {
(draft.sections[sectionKey].items as unknown[]).push(formData);
}
}
/**
* Finds and replaces an existing item in a section's items array by id.
* Handles both built-in sections and custom sections.
*/
export function updateSectionItem(
draft: WritableDraft<ResumeData>,
sectionKey: SectionType,
formData: { id: string } & Record<string, unknown>,
customSectionId?: string,
) {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData as never;
} else {
const items = draft.sections[sectionKey].items as Array<{ id: string }>;
const index = items.findIndex((item) => item.id === formData.id);
if (index !== -1) (items[index] as unknown as Record<string, unknown>) = formData;
}
}
+56
View File
@@ -612,3 +612,59 @@ describe("buildSkillSyncOperations", () => {
expect(operations).toHaveLength(0);
});
});
// --- Additional edge cases for branch coverage ---
describe("tailorOutputToPatches — edge cases", () => {
it("skips experience description patch when description is empty", () => {
const output: TailorOutput = {
...emptyTailorOutput,
experiences: [{ index: 0, description: "", roles: [] }],
};
const { operations } = tailorOutputToPatches(output, makeResumeData());
const expDescOps = operations.filter((op) => op.path.includes("/description"));
expect(expDescOps).toHaveLength(0);
});
it("skips reference description patch when description is empty", () => {
const output: TailorOutput = {
...emptyTailorOutput,
references: [{ index: 0, description: "" }],
};
const { operations } = tailorOutputToPatches(output, makeResumeData());
const refDescOps = operations.filter((op) => op.path.includes("/references/"));
expect(refDescOps).toHaveLength(0);
});
it("handles experience with null-like roles", () => {
const output: TailorOutput = {
...emptyTailorOutput,
experiences: [{ index: 0, description: "<p>Updated</p>" }],
};
const { operations } = tailorOutputToPatches(output, makeResumeData());
expect(operations.some((op) => op.path.includes("/description"))).toBe(true);
});
it("handles skill with empty proficiency", () => {
const output: TailorOutput = {
...emptyTailorOutput,
skills: [{ name: "Go", keywords: [], proficiency: "", icon: "", isNew: true }],
};
const { newSkills } = tailorOutputToPatches(output, makeResumeData());
expect(newSkills[0].proficiency).toBe("");
});
it("handles summary with undefined content", () => {
const output: TailorOutput = {
...emptyTailorOutput,
summary: { content: "" },
};
const { operations } = tailorOutputToPatches(output, makeResumeData());
expect(operations.filter((op) => op.path === "/summary/content")).toHaveLength(0);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vite-plus/test";
import { cn } from "./style";
describe("cn", () => {
it("merges simple class names", () => {
expect(cn("foo", "bar")).toBe("foo bar");
});
it("handles conditional classes (falsy values ignored)", () => {
const falsyValue = false;
expect(cn("base", falsyValue && "hidden", "active")).toBe("base active");
});
it("handles undefined and null", () => {
expect(cn("base", undefined, null, "end")).toBe("base end");
});
it("merges Tailwind classes (last wins)", () => {
expect(cn("p-4", "p-8")).toBe("p-8");
});
it("merges conflicting Tailwind utilities", () => {
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500");
});
it("keeps non-conflicting Tailwind classes", () => {
const result = cn("p-4", "m-2", "text-red-500");
expect(result).toContain("p-4");
expect(result).toContain("m-2");
expect(result).toContain("text-red-500");
});
it("handles empty input", () => {
expect(cn()).toBe("");
});
it("handles array input", () => {
expect(cn(["foo", "bar"])).toBe("foo bar");
});
it("handles object input", () => {
expect(cn({ foo: true, bar: false, baz: true })).toBe("foo baz");
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vite-plus/test";
vi.mock("@lingui/core/macro", () => ({
msg: (strings: TemplateStringsArray) => ({ id: strings[0] }),
}));
vi.mock("@tanstack/react-start", () => {
const chainable = () => new Proxy({}, { get: () => chainable });
return {
createIsomorphicFn: chainable,
createServerFn: chainable,
};
});
vi.mock("@tanstack/react-start/server", () => ({
getCookie: () => undefined,
setCookie: () => {},
}));
vi.mock("js-cookie", () => ({
default: { get: () => undefined },
}));
import { isTheme } from "./theme";
describe("isTheme", () => {
it("returns true for 'light'", () => {
expect(isTheme("light")).toBe(true);
});
it("returns true for 'dark'", () => {
expect(isTheme("dark")).toBe(true);
});
it("returns false for invalid theme strings", () => {
expect(isTheme("auto")).toBe(false);
expect(isTheme("system")).toBe(false);
expect(isTheme("")).toBe(false);
expect(isTheme("DARK")).toBe(false);
expect(isTheme("Light")).toBe(false);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vite-plus/test";
import { createUrl } from "./url";
describe("createUrl", () => {
it("returns empty strings for undefined url", () => {
expect(createUrl()).toEqual({ url: "", label: "" });
expect(createUrl(undefined)).toEqual({ url: "", label: "" });
});
it("uses url as label when label is not provided", () => {
expect(createUrl("https://example.com")).toEqual({
url: "https://example.com",
label: "https://example.com",
});
});
it("uses provided label", () => {
expect(createUrl("https://example.com", "Example")).toEqual({
url: "https://example.com",
label: "Example",
});
});
});
+8
View File
@@ -0,0 +1,8 @@
/**
* Creates a URL object with a url and label.
* Returns empty strings if no URL is provided.
*/
export function createUrl(url?: string, label?: string): { url: string; label: string } {
if (!url) return { url: "", label: "" };
return { url, label: label || url };
}