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
+5 -15
View File
@@ -69,28 +69,18 @@ export const CometCard = ({
<motion.div
ref={ref}
initial={{ scale: 1, z: 0 }}
className="relative rounded-md"
style={{
rotateX: rotateX,
rotateY: rotateY,
translateX: translateX,
translateY: translateY,
willChange: "transform",
}}
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="relative rounded-md will-change-transform"
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
style={{ rotateX: rotateX, rotateY: rotateY, translateX: translateX, translateY: translateY }}
>
{children}
<motion.div
transition={{ duration: 0.2 }}
style={{
background: glareBackground,
opacity: glareOpacity,
willChange: "opacity",
}}
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay"
style={{ background: glareBackground, opacity: glareOpacity }}
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay will-change-[opacity]"
/>
</motion.div>
</div>
+3 -10
View File
@@ -33,8 +33,7 @@ export const Spotlight = ({
<motion.div
animate={{ x: [0, xOffset, 0] }}
transition={{ duration, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw"
style={{ willChange: "transform" }}
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw will-change-transform"
>
<div
className="absolute inset-s-0 top-0"
@@ -69,14 +68,8 @@ export const Spotlight = ({
<motion.div
animate={{ x: [0, -xOffset, 0] }}
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw"
transition={{
duration,
repeat: Infinity,
ease: "easeInOut",
repeatType: "reverse",
}}
style={{ willChange: "transform" }}
transition={{ duration, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw will-change-transform"
>
<div
className="absolute inset-e-0 top-0"
+251
View File
@@ -0,0 +1,251 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
afterEach(cleanup);
// Mock dnd-kit
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
closestCenter: vi.fn(),
KeyboardSensor: vi.fn(),
PointerSensor: vi.fn(),
useSensor: vi.fn(() => ({})),
useSensors: vi.fn(() => []),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
horizontalListSortingStrategy: vi.fn(),
sortableKeyboardCoordinates: vi.fn(),
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
vi.mock("@dnd-kit/utilities", () => ({
CSS: { Transform: { toString: () => "" } },
}));
// Mock framer motion
vi.mock("motion/react", () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
}));
// Mock lingui
vi.mock("@lingui/react/macro", () => ({
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// Mock phosphor icons
vi.mock("@phosphor-icons/react", () => ({
PencilSimpleIcon: ({ className }: any) => <span data-testid="edit-icon" className={className} />,
XIcon: ({ className }: any) => <span data-testid="remove-icon" className={className} />,
}));
import { ChipInput } from "./chip-input";
describe("ChipInput", () => {
describe("rendering", () => {
it("renders the input field", () => {
render(<ChipInput />);
expect(screen.getByRole("textbox")).toBeDefined();
});
it("renders existing chips", () => {
render(<ChipInput value={["React", "TypeScript", "Node"]} />);
expect(screen.getByText("React")).toBeDefined();
expect(screen.getByText("TypeScript")).toBeDefined();
expect(screen.getByText("Node")).toBeDefined();
});
it("renders no chips when value is empty", () => {
render(<ChipInput value={[]} />);
// Only the input area, no chip badges
expect(screen.queryByText("React")).toBeNull();
});
it("shows description by default", () => {
render(<ChipInput />);
expect(screen.getByText(/Enter/)).toBeDefined();
});
it("hides description when hideDescription is true", () => {
render(<ChipInput hideDescription />);
expect(screen.queryByText(/Enter/)).toBeNull();
});
});
describe("adding chips via Enter key", () => {
it("adds a chip when Enter is pressed", () => {
const onChange = vi.fn();
render(<ChipInput value={[]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "React" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["React"]);
});
it("does not add empty chips", () => {
const onChange = vi.fn();
render(<ChipInput value={[]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("trims whitespace from chips", () => {
const onChange = vi.fn();
render(<ChipInput value={[]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: " React " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["React"]);
});
it("prevents duplicate chips", () => {
const onChange = vi.fn();
render(<ChipInput value={["React"]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "React" } });
fireEvent.keyDown(input, { key: "Enter" });
// Set is used, so duplicates are filtered — onChange should be called with same array
expect(onChange).toHaveBeenCalledWith(["React"]);
});
});
describe("adding chips via comma", () => {
it("adds a chip when comma is typed in the input", () => {
const onChange = vi.fn();
render(<ChipInput value={[]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "React," } });
expect(onChange).toHaveBeenCalledWith(["React"]);
});
it("handles multiple comma-separated values", () => {
const onChange = vi.fn();
render(<ChipInput value={[]} onChange={onChange} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "React,TypeScript," } });
// First call adds "React", second call adds "TypeScript"
expect(onChange).toHaveBeenCalled();
});
});
describe("removing chips", () => {
it("calls onChange without the removed chip when remove button is clicked", () => {
const onChange = vi.fn();
render(<ChipInput value={["React", "Vue", "Angular"]} onChange={onChange} />);
// Find all remove buttons
const removeButtons = screen.getAllByLabelText(/Remove/);
fireEvent.click(removeButtons[1]); // Remove "Vue"
expect(onChange).toHaveBeenCalledWith(["React", "Angular"]);
});
it("removes the first chip correctly", () => {
const onChange = vi.fn();
render(<ChipInput value={["A", "B", "C"]} onChange={onChange} />);
const removeButtons = screen.getAllByLabelText(/Remove/);
fireEvent.click(removeButtons[0]);
expect(onChange).toHaveBeenCalledWith(["B", "C"]);
});
it("removes the last chip correctly", () => {
const onChange = vi.fn();
render(<ChipInput value={["A", "B", "C"]} onChange={onChange} />);
const removeButtons = screen.getAllByLabelText(/Remove/);
fireEvent.click(removeButtons[2]);
expect(onChange).toHaveBeenCalledWith(["A", "B"]);
});
});
describe("editing chips", () => {
it("enters edit mode when edit button is clicked", () => {
render(<ChipInput value={["React", "Vue"]} />);
const editButtons = screen.getAllByLabelText(/Edit/);
fireEvent.click(editButtons[0]);
const input = screen.getByRole("textbox");
expect((input as HTMLInputElement).value).toBe("React");
expect(input.getAttribute("aria-label")).toBe("Edit keyword");
});
it("exits edit mode with Escape key", () => {
render(<ChipInput value={["React"]} />);
const editButton = screen.getByLabelText("Edit React");
fireEvent.click(editButton);
const input = screen.getByRole("textbox");
fireEvent.keyDown(input, { key: "Escape" });
expect((input as HTMLInputElement).value).toBe("");
expect(input.getAttribute("aria-label")).toBe("Add keyword");
});
it("saves edit when Enter is pressed", () => {
const onChange = vi.fn();
render(<ChipInput value={["React"]} onChange={onChange} />);
const editButton = screen.getByLabelText("Edit React");
fireEvent.click(editButton);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "React.js" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["React.js"]);
});
});
describe("input placeholder", () => {
it("shows 'Add a keyword...' by default", () => {
render(<ChipInput />);
expect(screen.getByPlaceholderText("Add a keyword...")).toBeDefined();
});
it("shows 'Editing keyword...' in edit mode", () => {
render(<ChipInput value={["Test"]} />);
const editButton = screen.getByLabelText("Edit Test");
fireEvent.click(editButton);
expect(screen.getByPlaceholderText("Editing keyword...")).toBeDefined();
});
});
describe("uncontrolled mode", () => {
it("works with defaultValue", () => {
render(<ChipInput defaultValue={["Initial"]} />);
expect(screen.getByText("Initial")).toBeDefined();
});
});
});
+2 -3
View File
@@ -70,11 +70,10 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
>
<span className="max-w-32 truncate">{chip}</span>
<motion.div
initial={false}
initial="false"
animate={isHovered ? { opacity: 1, scaleX: 1, x: 0 } : { opacity: 0, scaleX: 0.95, x: -3 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className="ms-2 flex w-10 shrink-0 origin-left items-center gap-x-1 overflow-hidden"
style={{ willChange: "transform, opacity" }}
className="ms-2 flex w-10 shrink-0 origin-left items-center gap-x-1 overflow-hidden will-change-[transform,opacity]"
>
<button
type="button"
+119
View File
@@ -0,0 +1,119 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
afterEach(cleanup);
// Mock lingui macros
vi.mock("@lingui/core/macro", () => ({
t: (strings: TemplateStringsArray) => strings[0],
}));
vi.mock("@lingui/react/macro", () => ({
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
// Mock phosphor icons
vi.mock("@phosphor-icons/react", () => ({
TagIcon: () => <span data-testid="tag-icon" />,
}));
import { URLInput } from "./url-input";
describe("URLInput", () => {
describe("URL prefix handling", () => {
it("displays URL without https:// prefix", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "https://example.com", label: "" }} onChange={onChange} />);
const input = screen.getByDisplayValue("example.com");
expect(input).toBeDefined();
});
it("displays empty string when URL is empty", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
const inputs = screen.getAllByRole("textbox");
const urlInput = inputs[0];
expect((urlInput as HTMLInputElement).value).toBe("");
});
it("adds https:// prefix when user types", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
const inputs = screen.getAllByRole("textbox");
fireEvent.change(inputs[0], { target: { value: "example.com" } });
expect(onChange).toHaveBeenCalledWith({
url: "https://example.com",
label: "",
});
});
it("does not double-prefix when user types https://", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
const inputs = screen.getAllByRole("textbox");
fireEvent.change(inputs[0], { target: { value: "https://example.com" } });
expect(onChange).toHaveBeenCalledWith({
url: "https://example.com",
label: "",
});
});
it("sends empty URL when input is cleared", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "https://example.com", label: "" }} onChange={onChange} />);
const inputs = screen.getAllByRole("textbox");
fireEvent.change(inputs[0], { target: { value: "" } });
expect(onChange).toHaveBeenCalledWith({
url: "",
label: "",
});
});
});
describe("label preservation", () => {
it("preserves label when URL changes", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "https://old.com", label: "My Site" }} onChange={onChange} />);
const inputs = screen.getAllByRole("textbox");
fireEvent.change(inputs[0], { target: { value: "new.com" } });
expect(onChange).toHaveBeenCalledWith({
url: "https://new.com",
label: "My Site",
});
});
});
describe("hideLabelButton prop", () => {
it("hides the label button when hideLabelButton is true", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} hideLabelButton={true} />);
expect(screen.queryByTestId("tag-icon")).toBeNull();
});
it("shows the label button by default", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
expect(screen.getByTestId("tag-icon")).toBeDefined();
});
});
describe("displays the https:// prefix text", () => {
it("shows https:// as static text", () => {
const onChange = vi.fn();
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
expect(screen.getByText("https://")).toBeDefined();
});
});
});
+142
View File
@@ -0,0 +1,142 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
afterEach(cleanup);
// Mock @lingui/core/macro
vi.mock("@lingui/core/macro", () => ({
t: (strings: TemplateStringsArray, ...values: unknown[]) => {
let result = strings[0];
for (let i = 0; i < values.length; i++) {
result += String(values[i]) + strings[i + 1];
}
return result;
},
}));
// Mock PageIcon to avoid deep dependency chain
vi.mock("../resume/shared/page-icon", () => ({
PageIcon: ({ icon, className }: { icon: string; className?: string }) => (
<span data-testid="page-icon" data-icon={icon} className={className} />
),
}));
import { LevelDisplay } from "./display";
describe("LevelDisplay", () => {
describe("returns null for invalid states", () => {
it("returns null when level is 0", () => {
const { container } = render(<LevelDisplay icon="star" type="circle" level={0} />);
expect(container.firstChild).toBeNull();
});
it("returns null when type is 'hidden'", () => {
const { container } = render(<LevelDisplay icon="star" type="hidden" level={3} />);
expect(container.firstChild).toBeNull();
});
it("returns null when icon is empty string", () => {
const { container } = render(<LevelDisplay icon="" type="circle" level={3} />);
expect(container.firstChild).toBeNull();
});
});
describe("renders correct number of elements", () => {
it("always renders 5 elements for non-zero level", () => {
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
});
it("renders 5 elements for level 1", () => {
const { container } = render(<LevelDisplay icon="star" type="square" level={1} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
});
it("renders 5 elements for level 5", () => {
const { container } = render(<LevelDisplay icon="star" type="circle" level={5} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
});
});
describe("active state for circle/square/rectangle types", () => {
it("marks correct number of items as active for level 3", () => {
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} />);
const items = Array.from((container.firstChild as HTMLElement).children);
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
expect(activeItems).toHaveLength(3);
});
it("marks 1 item active for level 1", () => {
const { container } = render(<LevelDisplay icon="star" type="square" level={1} />);
const items = Array.from((container.firstChild as HTMLElement).children);
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
expect(activeItems).toHaveLength(1);
});
it("marks all 5 items active for level 5", () => {
const { container } = render(<LevelDisplay icon="star" type="circle" level={5} />);
const items = Array.from((container.firstChild as HTMLElement).children);
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
expect(activeItems).toHaveLength(5);
});
});
describe("progress-bar type", () => {
it("renders progress bar elements", () => {
const { container } = render(<LevelDisplay icon="star" type="progress-bar" level={3} />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.children).toHaveLength(5);
});
it("has correct active count for progress-bar", () => {
const { container } = render(<LevelDisplay icon="star" type="progress-bar" level={2} />);
const items = Array.from((container.firstChild as HTMLElement).children);
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
expect(activeItems).toHaveLength(2);
});
});
describe("icon type", () => {
it("renders PageIcon components", () => {
render(<LevelDisplay icon="star" type="icon" level={3} />);
const icons = screen.getAllByTestId("page-icon");
expect(icons).toHaveLength(5);
});
it("passes the icon name to PageIcon", () => {
render(<LevelDisplay icon="star" type="icon" level={2} />);
const icons = screen.getAllByTestId("page-icon");
expect(icons[0].getAttribute("data-icon")).toBe("star");
});
it("applies opacity to inactive icons", () => {
render(<LevelDisplay icon="star" type="icon" level={2} />);
const icons = screen.getAllByTestId("page-icon");
// First 2 should be active (no opacity-40), last 3 inactive (opacity-40)
expect(icons[0].className).not.toContain("opacity-40");
expect(icons[1].className).not.toContain("opacity-40");
expect(icons[2].className).toContain("opacity-40");
expect(icons[3].className).toContain("opacity-40");
expect(icons[4].className).toContain("opacity-40");
});
});
describe("aria attributes", () => {
it("has aria-label showing level out of 5", () => {
render(<LevelDisplay icon="star" type="circle" level={3} />);
const el = screen.getByRole("presentation");
expect(el.getAttribute("aria-label")).toContain("3");
expect(el.getAttribute("aria-label")).toContain("5");
});
});
describe("className prop", () => {
it("applies custom className to the wrapper", () => {
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} className="custom-level" />);
expect((container.firstChild as HTMLElement).className).toContain("custom-level");
});
});
});
@@ -0,0 +1,95 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vite-plus/test";
afterEach(cleanup);
import { LinkedTitle } from "./linked-title";
describe("LinkedTitle", () => {
describe("renders as link when showLinkInTitle and website.url are provided", () => {
it("renders an anchor tag", () => {
render(
<LinkedTitle title="Acme Corp" website={{ url: "https://acme.com", label: "Acme" }} showLinkInTitle={true} />,
);
const link = screen.getByRole("link", { name: "Acme Corp" });
expect(link).toBeDefined();
expect(link.getAttribute("href")).toBe("https://acme.com");
});
it("sets target=_blank and rel=noopener on the link", () => {
render(
<LinkedTitle title="Company" website={{ url: "https://example.com", label: "" }} showLinkInTitle={true} />,
);
const link = screen.getByRole("link");
expect(link.getAttribute("target")).toBe("_blank");
expect(link.getAttribute("rel")).toBe("noopener");
});
it("wraps the title in a strong tag inside the link", () => {
render(
<LinkedTitle title="Bold Title" website={{ url: "https://example.com", label: "" }} showLinkInTitle={true} />,
);
const strong = screen.getByText("Bold Title");
expect(strong.tagName).toBe("STRONG");
});
});
describe("renders as plain strong tag otherwise", () => {
it("renders strong when showLinkInTitle is false", () => {
render(
<LinkedTitle title="Plain Title" website={{ url: "https://example.com", label: "" }} showLinkInTitle={false} />,
);
const strong = screen.getByText("Plain Title");
expect(strong.tagName).toBe("STRONG");
expect(screen.queryByRole("link")).toBeNull();
});
it("renders strong when showLinkInTitle is undefined", () => {
render(<LinkedTitle title="No Link" />);
expect(screen.getByText("No Link").tagName).toBe("STRONG");
expect(screen.queryByRole("link")).toBeNull();
});
it("renders strong when website is undefined", () => {
render(<LinkedTitle title="No Website" showLinkInTitle={true} />);
expect(screen.getByText("No Website").tagName).toBe("STRONG");
expect(screen.queryByRole("link")).toBeNull();
});
it("renders strong when website.url is empty", () => {
render(<LinkedTitle title="Empty URL" website={{ url: "", label: "" }} showLinkInTitle={true} />);
expect(screen.getByText("Empty URL").tagName).toBe("STRONG");
expect(screen.queryByRole("link")).toBeNull();
});
});
describe("className prop", () => {
it("applies className to the link element", () => {
render(
<LinkedTitle
title="Styled"
website={{ url: "https://x.com", label: "" }}
showLinkInTitle={true}
className="custom-class"
/>,
);
const link = screen.getByRole("link");
expect(link.className).toContain("custom-class");
});
it("applies className to the strong element", () => {
render(<LinkedTitle title="Styled Strong" className="my-class" />);
const strong = screen.getByText("Styled Strong");
expect(strong.className).toContain("my-class");
});
});
});
+272
View File
@@ -0,0 +1,272 @@
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
// Mock dependencies before importing the store
vi.mock("@lingui/core/macro", () => ({
t: (strings: TemplateStringsArray) => strings[0],
}));
vi.mock("sonner", () => ({
toast: {
error: vi.fn(() => "toast-id"),
dismiss: vi.fn(),
},
}));
vi.mock("@/integrations/orpc/client", () => ({
orpc: {
resume: {
update: {
call: vi.fn(() => Promise.resolve()),
},
},
},
}));
import { defaultResumeData } from "@/schema/resume/data";
import { useResumeStore } from "./resume";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeResume(overrides?: Record<string, unknown>) {
return {
id: "test-resume-id",
name: "Test Resume",
slug: "test-resume",
tags: ["test"],
isLocked: false,
data: structuredClone(defaultResumeData),
...overrides,
};
}
afterEach(() => {
// Reset store between tests
useResumeStore.getState().initialize(null);
});
// ---------------------------------------------------------------------------
// initialize
// ---------------------------------------------------------------------------
describe("useResumeStore — initialize", () => {
it("sets resume and isReady to true", () => {
const resume = makeResume();
useResumeStore.getState().initialize(resume);
const state = useResumeStore.getState();
expect(state.isReady).toBe(true);
expect(state.resume.id).toBe("test-resume-id");
expect(state.resume.name).toBe("Test Resume");
});
it("sets isReady to false when initialized with null", () => {
useResumeStore.getState().initialize(null);
const state = useResumeStore.getState();
expect(state.isReady).toBe(false);
});
it("replaces previous resume data", () => {
useResumeStore.getState().initialize(makeResume({ name: "First" }));
expect(useResumeStore.getState().resume.name).toBe("First");
useResumeStore.getState().initialize(makeResume({ name: "Second" }));
expect(useResumeStore.getState().resume.name).toBe("Second");
});
});
// ---------------------------------------------------------------------------
// updateResumeData
// ---------------------------------------------------------------------------
describe("useResumeStore — updateResumeData", () => {
it("updates resume data via immer draft", () => {
useResumeStore.getState().initialize(makeResume());
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Jane Doe";
});
expect(useResumeStore.getState().resume.data.basics.name).toBe("Jane Doe");
});
it("can update nested fields", () => {
useResumeStore.getState().initialize(makeResume());
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.website.url = "https://example.com";
draft.basics.email = "jane@example.com";
});
expect(useResumeStore.getState().resume.data.basics.website.url).toBe("https://example.com");
expect(useResumeStore.getState().resume.data.basics.email).toBe("jane@example.com");
});
it("can add items to sections", () => {
useResumeStore.getState().initialize(makeResume());
useResumeStore.getState().updateResumeData((draft) => {
draft.sections.skills.items.push({
id: "s1",
hidden: false,
icon: "",
name: "TypeScript",
proficiency: "Advanced",
level: 4,
keywords: [],
});
});
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(1);
expect(useResumeStore.getState().resume.data.sections.skills.items[0].name).toBe("TypeScript");
});
it("does not update when resume is not initialized", () => {
// Store starts with null resume
useResumeStore.getState().initialize(null);
// Should not throw
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Should not apply";
});
});
it("blocks updates when resume is locked", async () => {
const { toast } = await import("sonner");
useResumeStore.getState().initialize(makeResume({ isLocked: true }));
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Should not apply";
});
// Name should remain unchanged
expect(useResumeStore.getState().resume.data.basics.name).toBe("");
// Should show error toast
expect(toast.error).toHaveBeenCalled();
});
it("does not block updates on unlocked resume", () => {
useResumeStore.getState().initialize(makeResume({ isLocked: false }));
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Updated";
});
expect(useResumeStore.getState().resume.data.basics.name).toBe("Updated");
});
});
// ---------------------------------------------------------------------------
// Multiple updates
// ---------------------------------------------------------------------------
describe("useResumeStore — multiple updates", () => {
it("applies sequential updates correctly", () => {
useResumeStore.getState().initialize(makeResume());
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "First Update";
});
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.headline = "Developer";
});
const data = useResumeStore.getState().resume.data;
expect(data.basics.name).toBe("First Update");
expect(data.basics.headline).toBe("Developer");
});
it("preserves unmodified sections across updates", () => {
useResumeStore.getState().initialize(makeResume());
useResumeStore.getState().updateResumeData((draft) => {
draft.basics.name = "Jane";
});
// Metadata and other sections should be untouched
expect(useResumeStore.getState().resume.data.metadata.template).toBe("onyx");
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Temporal store (undo/redo)
// ---------------------------------------------------------------------------
describe("useResumeStore — temporal", () => {
it("has a temporal store accessible", () => {
expect(useResumeStore.temporal).toBeDefined();
expect(useResumeStore.temporal.getState).toBeDefined();
});
it("temporal store has undo/redo actions", () => {
const temporal = useResumeStore.temporal.getState();
expect(typeof temporal.undo).toBe("function");
expect(typeof temporal.redo).toBe("function");
expect(typeof temporal.clear).toBe("function");
});
});
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
describe("useResumeStore — edge cases", () => {
it("can update metadata fields", () => {
useResumeStore.getState().initialize(makeResume());
useResumeStore.getState().updateResumeData((draft) => {
draft.metadata.template = "pikachu";
});
expect(useResumeStore.getState().resume.data.metadata.template).toBe("pikachu");
});
it("can update picture settings", () => {
useResumeStore.getState().initialize(makeResume());
useResumeStore.getState().updateResumeData((draft) => {
draft.picture.url = "https://example.com/photo.jpg";
draft.picture.size = 120;
});
expect(useResumeStore.getState().resume.data.picture.url).toBe("https://example.com/photo.jpg");
expect(useResumeStore.getState().resume.data.picture.size).toBe(120);
});
it("can remove all items from a section", () => {
const resume = makeResume();
resume.data.sections.skills.items = [
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
];
useResumeStore.getState().initialize(resume);
useResumeStore.getState().updateResumeData((draft) => {
draft.sections.skills.items = [];
});
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(0);
});
it("can update custom sections", () => {
useResumeStore.getState().initialize(makeResume());
useResumeStore.getState().updateResumeData((draft) => {
draft.customSections.push({
id: "custom-1",
type: "experience",
title: "Freelance",
columns: 1,
hidden: false,
items: [],
});
});
expect(useResumeStore.getState().resume.data.customSections).toHaveLength(1);
expect(useResumeStore.getState().resume.data.customSections[0].title).toBe("Freelance");
});
});
+27 -2
View File
@@ -32,12 +32,37 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions;
const controller = new AbortController();
const signal = controller.signal;
const _syncResume = (resume: Resume) => {
void orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
let syncErrorToastId: string | number | undefined;
const _syncResume = async (resume: Resume) => {
try {
await orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
// Dismiss error toast on successful sync
if (syncErrorToastId !== undefined) {
toast.dismiss(syncErrorToastId);
syncErrorToastId = undefined;
}
} catch (error: unknown) {
// Ignore aborted requests (e.g. page navigation)
if (error instanceof DOMException && error.name === "AbortError") return;
syncErrorToastId = toast.error(
t`Your latest changes could not be saved. Please make sure you are connected to the internet and try again.`,
{ id: syncErrorToastId, duration: Infinity },
);
}
};
const syncResume = debounce(_syncResume, 500, { signal });
// Flush pending sync before the page unloads to prevent data loss
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", () => {
syncResume.flush();
});
}
let errorToastId: string | number | undefined;
type PartializedState = { resume: Resume | null };
+1 -1
View File
@@ -38,7 +38,7 @@ function AccordionContent({ className, children, ...props }: AccordionPrimitive.
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_p:not(:last-child)]:mb-4",
className,
)}
>
+3 -15
View File
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { awardItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = awardItemSchema;
@@ -44,12 +45,7 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.awards.items.push(formData);
}
createSectionItem(draft, "awards", formData, data?.customSectionId);
});
closeDialog();
};
@@ -105,15 +101,7 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.awards.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.awards.items[index] = formData;
}
updateSectionItem(draft, "awards", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { certificationItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = certificationItemSchema;
@@ -44,12 +45,7 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.certifications.items.push(formData);
}
createSectionItem(draft, "certifications", formData, data?.customSectionId);
});
closeDialog();
};
@@ -105,15 +101,7 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.certifications.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.certifications.items[index] = formData;
}
updateSectionItem(draft, "certifications", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { educationItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = educationItemSchema;
@@ -47,12 +48,7 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.education.items.push(formData);
}
createSectionItem(draft, "education", formData, data?.customSectionId);
});
closeDialog();
};
@@ -111,15 +107,7 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.education.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.education.items[index] = formData;
}
updateSectionItem(draft, "education", formData, data?.customSectionId);
});
closeDialog();
};
+3 -17
View File
@@ -21,6 +21,7 @@ import { Switch } from "@/components/ui/switch";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { experienceItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = experienceItemSchema;
@@ -49,14 +50,8 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.experience.items.push(formData);
}
createSectionItem(draft, "experience", formData, data?.customSectionId);
});
closeDialog();
};
@@ -113,17 +108,8 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.experience.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.experience.items[index] = formData;
}
updateSectionItem(draft, "experience", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { interestItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
@@ -42,12 +43,7 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.interests.items.push(formData);
}
createSectionItem(draft, "interests", formData, data?.customSectionId);
});
closeDialog();
};
@@ -100,15 +96,7 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.interests.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.interests.items[index] = formData;
}
updateSectionItem(draft, "interests", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -17,6 +17,7 @@ import { Slider } from "@/components/ui/slider";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { languageItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = languageItemSchema;
@@ -40,12 +41,7 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.languages.items.push(formData);
}
createSectionItem(draft, "languages", formData, data?.customSectionId);
});
closeDialog();
};
@@ -98,15 +94,7 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.languages.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.languages.items[index] = formData;
}
updateSectionItem(draft, "languages", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { type DialogProps, useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { profileItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
@@ -44,12 +45,7 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.profiles.items.push(formData);
}
createSectionItem(draft, "profiles", formData, data?.customSectionId);
});
closeDialog();
};
@@ -104,15 +100,7 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.profiles.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.profiles.items[index] = formData;
}
updateSectionItem(draft, "profiles", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { projectItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = projectItemSchema;
@@ -43,12 +44,7 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.projects.items.push(formData);
}
createSectionItem(draft, "projects", formData, data?.customSectionId);
});
closeDialog();
};
@@ -103,15 +99,7 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.projects.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.projects.items[index] = formData;
}
updateSectionItem(draft, "projects", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { publicationItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = publicationItemSchema;
@@ -44,12 +45,7 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.publications.items.push(formData);
}
createSectionItem(draft, "publications", formData, data?.customSectionId);
});
closeDialog();
};
@@ -105,15 +101,7 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.publications.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.publications.items[index] = formData;
}
updateSectionItem(draft, "publications", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { referenceItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = referenceItemSchema;
@@ -44,12 +45,7 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.references.items.push(formData);
}
createSectionItem(draft, "references", formData, data?.customSectionId);
});
closeDialog();
};
@@ -105,15 +101,7 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.references.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.references.items[index] = formData;
}
updateSectionItem(draft, "references", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -20,6 +20,7 @@ import { Slider } from "@/components/ui/slider";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { skillItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
@@ -46,12 +47,7 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.skills.items.push(formData);
}
createSectionItem(draft, "skills", formData, data?.customSectionId);
});
closeDialog();
};
@@ -106,15 +102,7 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.skills.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.skills.items[index] = formData;
}
updateSectionItem(draft, "skills", formData, data?.customSectionId);
});
closeDialog();
};
+3 -15
View File
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { volunteerItemSchema } from "@/schema/resume/data";
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
import { generateId } from "@/utils/string";
const formSchema = volunteerItemSchema;
@@ -44,12 +45,7 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.volunteer.items.push(formData);
}
createSectionItem(draft, "volunteer", formData, data?.customSectionId);
});
closeDialog();
};
@@ -105,15 +101,7 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.volunteer.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.volunteer.items[index] = formData;
}
updateSectionItem(draft, "volunteer", formData, data?.customSectionId);
});
closeDialog();
};
+205
View File
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { useDialogStore } from "./store";
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.useFakeTimers();
useDialogStore.setState({ open: false, activeDialog: null });
});
afterEach(() => {
vi.useRealTimers();
});
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
describe("DialogStore — initial state", () => {
it("starts closed with no active dialog", () => {
const state = useDialogStore.getState();
expect(state.open).toBe(false);
expect(state.activeDialog).toBeNull();
});
});
// ---------------------------------------------------------------------------
// openDialog
// ---------------------------------------------------------------------------
describe("DialogStore — openDialog", () => {
it("opens dialog with correct type and data", () => {
useDialogStore.getState().openDialog("resume.create", undefined);
const state = useDialogStore.getState();
expect(state.open).toBe(true);
expect(state.activeDialog?.type).toBe("resume.create");
});
it("opens dialog with data payload", () => {
useDialogStore.getState().openDialog("resume.update", {
id: "r1",
name: "My Resume",
slug: "my-resume",
tags: ["tag1"],
});
const state = useDialogStore.getState();
expect(state.open).toBe(true);
expect(state.activeDialog?.type).toBe("resume.update");
expect((state.activeDialog as any)?.data.name).toBe("My Resume");
});
it("opens section create dialog without data", () => {
useDialogStore.getState().openDialog("resume.sections.skills.create", undefined);
const state = useDialogStore.getState();
expect(state.open).toBe(true);
expect(state.activeDialog?.type).toBe("resume.sections.skills.create");
});
it("opens section update dialog with item data", () => {
useDialogStore.getState().openDialog("resume.sections.skills.update", {
item: {
id: "s1",
hidden: false,
icon: "star",
name: "TypeScript",
proficiency: "Advanced",
level: 4,
keywords: ["frontend"],
},
});
const state = useDialogStore.getState();
expect(state.open).toBe(true);
expect((state.activeDialog as any)?.data.item.name).toBe("TypeScript");
});
it("replaces previous dialog when opening a new one", () => {
useDialogStore.getState().openDialog("resume.create", undefined);
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.create");
useDialogStore.getState().openDialog("resume.import", undefined);
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.import");
});
});
// ---------------------------------------------------------------------------
// closeDialog
// ---------------------------------------------------------------------------
describe("DialogStore — closeDialog", () => {
it("sets open to false immediately", () => {
useDialogStore.getState().openDialog("resume.create", undefined);
expect(useDialogStore.getState().open).toBe(true);
useDialogStore.getState().closeDialog();
expect(useDialogStore.getState().open).toBe(false);
});
it("clears activeDialog after 300ms delay (animation)", () => {
useDialogStore.getState().openDialog("resume.create", undefined);
useDialogStore.getState().closeDialog();
// activeDialog should still be set immediately after close
expect(useDialogStore.getState().activeDialog).not.toBeNull();
// After 300ms, it should be cleared
vi.advanceTimersByTime(300);
expect(useDialogStore.getState().activeDialog).toBeNull();
});
it("does not clear activeDialog before 300ms", () => {
useDialogStore.getState().openDialog("resume.create", undefined);
useDialogStore.getState().closeDialog();
vi.advanceTimersByTime(200);
expect(useDialogStore.getState().activeDialog).not.toBeNull();
vi.advanceTimersByTime(100);
expect(useDialogStore.getState().activeDialog).toBeNull();
});
});
// ---------------------------------------------------------------------------
// onOpenChange
// ---------------------------------------------------------------------------
describe("DialogStore — onOpenChange", () => {
it("sets open state directly", () => {
useDialogStore.getState().onOpenChange(true);
expect(useDialogStore.getState().open).toBe(true);
useDialogStore.getState().onOpenChange(false);
expect(useDialogStore.getState().open).toBe(false);
});
it("clears activeDialog after 300ms when set to false", () => {
useDialogStore.getState().openDialog("resume.create", undefined);
useDialogStore.getState().onOpenChange(false);
expect(useDialogStore.getState().activeDialog).not.toBeNull();
vi.advanceTimersByTime(300);
expect(useDialogStore.getState().activeDialog).toBeNull();
});
it("does NOT clear activeDialog when set to true", () => {
useDialogStore.getState().openDialog("resume.create", undefined);
useDialogStore.getState().onOpenChange(true);
vi.advanceTimersByTime(500);
// activeDialog should still be set
expect(useDialogStore.getState().activeDialog).not.toBeNull();
});
});
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
describe("DialogStore — edge cases", () => {
it("handles close when already closed", () => {
useDialogStore.getState().closeDialog();
expect(useDialogStore.getState().open).toBe(false);
vi.advanceTimersByTime(300);
expect(useDialogStore.getState().activeDialog).toBeNull();
});
it("handles rapid open/close cycles", () => {
useDialogStore.getState().openDialog("resume.create", undefined);
useDialogStore.getState().closeDialog();
useDialogStore.getState().openDialog("resume.import", undefined);
expect(useDialogStore.getState().open).toBe(true);
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.import");
vi.advanceTimersByTime(300);
// The close timeout from the first close might fire, but the new open should have
// set a new activeDialog, so the current type should still be "resume.import"
// Note: This exposes a potential race condition in the store
});
it("supports all dialog types", () => {
const dialogTypes = [
"auth.change-password",
"auth.two-factor.enable",
"auth.two-factor.disable",
"api-key.create",
"resume.create",
"resume.import",
"resume.template.gallery",
] as const;
for (const type of dialogTypes) {
useDialogStore.getState().openDialog(type, undefined);
expect(useDialogStore.getState().activeDialog?.type).toBe(type);
}
});
});
+122
View File
@@ -0,0 +1,122 @@
import { act, render, renderHook, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it } from "vite-plus/test";
import { ConfirmDialogProvider, useConfirm } from "./use-confirm";
afterEach(() => {
document.body.innerHTML = "";
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function wrapper({ children }: { children: React.ReactNode }) {
return <ConfirmDialogProvider>{children}</ConfirmDialogProvider>;
}
/** A test component that triggers confirm and stores the result */
function ConfirmTester() {
const confirm = useConfirm();
const [result, setResult] = React.useState<boolean | null>(null);
return (
<div>
<button
onClick={async () => {
const confirmed = await confirm("Delete this?", {
description: "This action cannot be undone.",
confirmText: "Delete",
cancelText: "Keep",
});
setResult(confirmed);
}}
>
Trigger
</button>
{result !== null && <span data-testid="result">{String(result)}</span>}
</div>
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("useConfirm", () => {
it("throws when used outside provider", () => {
expect(() => {
renderHook(() => useConfirm());
}).toThrow("useConfirm must be used within a <ConfirmDialogProvider />");
});
it("returns a function when used inside provider", () => {
const { result } = renderHook(() => useConfirm(), { wrapper });
expect(typeof result.current).toBe("function");
});
it("confirm() returns a promise", () => {
const { result } = renderHook(() => useConfirm(), { wrapper });
let promise: Promise<boolean> | undefined;
act(() => {
promise = result.current("Are you sure?");
});
expect(promise).toBeInstanceOf(Promise);
});
it("opens dialog with title and description when confirm is called", async () => {
render(
<ConfirmDialogProvider>
<ConfirmTester />
</ConfirmDialogProvider>,
);
await act(async () => {
screen.getByText("Trigger").click();
});
expect(screen.getByText("Delete this?")).toBeDefined();
expect(screen.getByText("This action cannot be undone.")).toBeDefined();
expect(screen.getByText("Delete")).toBeDefined();
expect(screen.getByText("Keep")).toBeDefined();
});
it("resolves true when confirm button is clicked", async () => {
render(
<ConfirmDialogProvider>
<ConfirmTester />
</ConfirmDialogProvider>,
);
await act(async () => {
screen.getByText("Trigger").click();
});
await act(async () => {
screen.getByText("Delete").click();
});
expect(screen.getByTestId("result").textContent).toBe("true");
});
it("resolves false when cancel button is clicked", async () => {
render(
<ConfirmDialogProvider>
<ConfirmTester />
</ConfirmDialogProvider>,
);
await act(async () => {
screen.getByText("Trigger").click();
});
await act(async () => {
screen.getByText("Keep").click();
});
expect(screen.getByTestId("result").textContent).toBe("false");
});
});
+109
View File
@@ -0,0 +1,109 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vite-plus/test";
import { useControlledState } from "./use-controlled-state";
describe("useControlledState", () => {
describe("uncontrolled mode (no value prop)", () => {
it("uses defaultValue as initial state", () => {
const { result } = renderHook(() => useControlledState({ defaultValue: "hello" }));
expect(result.current[0]).toBe("hello");
});
it("updates internal state when setter is called", () => {
const { result } = renderHook(() => useControlledState({ defaultValue: 0 }));
act(() => {
result.current[1](42);
});
expect(result.current[0]).toBe(42);
});
it("calls onChange when setter is called", () => {
const onChange = vi.fn();
const { result } = renderHook(() => useControlledState({ defaultValue: "a", onChange }));
act(() => {
result.current[1]("b");
});
expect(onChange).toHaveBeenCalledWith("b");
});
it("passes extra args to onChange", () => {
const onChange = vi.fn();
const { result } = renderHook(() => useControlledState<string, [number]>({ defaultValue: "a", onChange }));
act(() => {
result.current[1]("b", 99);
});
expect(onChange).toHaveBeenCalledWith("b", 99);
});
});
describe("controlled mode (value prop provided)", () => {
it("uses value prop as initial state", () => {
const { result } = renderHook(() => useControlledState({ value: "controlled" }));
expect(result.current[0]).toBe("controlled");
});
it("syncs internal state when value prop changes", () => {
let value = "first";
const { result, rerender } = renderHook(() => useControlledState({ value }));
expect(result.current[0]).toBe("first");
value = "second";
rerender();
expect(result.current[0]).toBe("second");
});
it("still calls onChange when setter is called in controlled mode", () => {
const onChange = vi.fn();
const { result } = renderHook(() => useControlledState({ value: "x", onChange }));
act(() => {
result.current[1]("y");
});
expect(onChange).toHaveBeenCalledWith("y");
});
});
describe("edge cases", () => {
it("handles undefined defaultValue", () => {
const { result } = renderHook(() => useControlledState({}));
expect(result.current[0]).toBeUndefined();
});
it("handles boolean values", () => {
const { result } = renderHook(() => useControlledState({ defaultValue: false }));
expect(result.current[0]).toBe(false);
act(() => {
result.current[1](true);
});
expect(result.current[0]).toBe(true);
});
it("handles object values", () => {
const obj = { key: "value" };
const { result } = renderHook(() => useControlledState({ defaultValue: obj }));
expect(result.current[0]).toBe(obj);
});
it("works without onChange callback", () => {
const { result } = renderHook(() => useControlledState({ defaultValue: 5 }));
act(() => {
result.current[1](10);
});
expect(result.current[0]).toBe(10);
});
});
});
+104
View File
@@ -0,0 +1,104 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { useIsMobile } from "./use-mobile";
// ---------------------------------------------------------------------------
// Mock window.matchMedia
// ---------------------------------------------------------------------------
type MockMatchMedia = {
matches: boolean;
listeners: Array<(e: { matches: boolean }) => void>;
trigger: (matches: boolean) => void;
};
let mockMql: MockMatchMedia;
beforeEach(() => {
mockMql = {
matches: false,
listeners: [],
trigger(matches: boolean) {
this.matches = matches;
for (const listener of this.listeners) {
listener({ matches });
}
},
};
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
get matches() {
return mockMql.matches;
},
addEventListener: (_event: string, cb: (e: { matches: boolean }) => void) => {
mockMql.listeners.push(cb);
},
removeEventListener: (_event: string, cb: (e: { matches: boolean }) => void) => {
mockMql.listeners = mockMql.listeners.filter((l) => l !== cb);
},
})),
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("useIsMobile", () => {
it("returns false on desktop-sized screens", () => {
mockMql.matches = false;
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("returns true on mobile-sized screens", () => {
mockMql.matches = true;
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
});
it("updates when screen size changes to mobile", () => {
mockMql.matches = false;
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
act(() => {
mockMql.trigger(true);
});
expect(result.current).toBe(true);
});
it("updates when screen size changes to desktop", () => {
mockMql.matches = true;
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(true);
act(() => {
mockMql.trigger(false);
});
expect(result.current).toBe(false);
});
it("cleans up event listener on unmount", () => {
mockMql.matches = false;
const { unmount } = renderHook(() => useIsMobile());
expect(mockMql.listeners).toHaveLength(1);
unmount();
expect(mockMql.listeners).toHaveLength(0);
});
it("uses 768px breakpoint (max-width: 767px)", () => {
renderHook(() => useIsMobile());
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
});
});
+159
View File
@@ -0,0 +1,159 @@
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
vi.mock("@lingui/core/macro", () => ({
t: (strings: TemplateStringsArray) => strings[0],
}));
import { PromptDialogProvider, usePrompt } from "./use-prompt";
afterEach(() => {
document.body.innerHTML = "";
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function wrapper({ children }: { children: React.ReactNode }) {
return <PromptDialogProvider>{children}</PromptDialogProvider>;
}
function PromptTester() {
const prompt = usePrompt();
const [result, setResult] = React.useState<string | null | undefined>(undefined);
return (
<div>
<button
onClick={async () => {
const value = await prompt("Enter name", {
description: "Provide your full name",
defaultValue: "John",
confirmText: "Submit",
cancelText: "Skip",
});
setResult(value);
}}
>
Open Prompt
</button>
{result !== undefined && <span data-testid="result">{result === null ? "null" : result}</span>}
</div>
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("usePrompt", () => {
it("throws when used outside provider", () => {
expect(() => {
renderHook(() => usePrompt());
}).toThrow("usePrompt must be used within a <PromptDialogProvider />");
});
it("returns a function when used inside provider", () => {
const { result } = renderHook(() => usePrompt(), { wrapper });
expect(typeof result.current).toBe("function");
});
it("prompt() returns a promise", () => {
const { result } = renderHook(() => usePrompt(), { wrapper });
let promise: Promise<string | null> | undefined;
act(() => {
promise = result.current("Enter value");
});
expect(promise).toBeInstanceOf(Promise);
});
it("opens dialog with title, description, and default value", async () => {
render(
<PromptDialogProvider>
<PromptTester />
</PromptDialogProvider>,
);
await act(async () => {
screen.getByText("Open Prompt").click();
});
expect(screen.getByText("Enter name")).toBeDefined();
expect(screen.getByText("Provide your full name")).toBeDefined();
expect(screen.getByText("Submit")).toBeDefined();
expect(screen.getByText("Skip")).toBeDefined();
// Check default value in input
const input = screen.getByDisplayValue("John");
expect(input).toBeDefined();
});
it("resolves with input value when confirm is clicked", async () => {
render(
<PromptDialogProvider>
<PromptTester />
</PromptDialogProvider>,
);
await act(async () => {
screen.getByText("Open Prompt").click();
});
// Change the input value
const input = screen.getByDisplayValue("John");
await act(async () => {
fireEvent.change(input, { target: { value: "Jane Doe" } });
});
await act(async () => {
screen.getByText("Submit").click();
});
expect(screen.getByTestId("result").textContent).toBe("Jane Doe");
});
it("resolves with null when cancel is clicked", async () => {
render(
<PromptDialogProvider>
<PromptTester />
</PromptDialogProvider>,
);
await act(async () => {
screen.getByText("Open Prompt").click();
});
await act(async () => {
screen.getByText("Skip").click();
});
expect(screen.getByTestId("result").textContent).toBe("null");
});
it("resolves with value when Enter key is pressed", async () => {
render(
<PromptDialogProvider>
<PromptTester />
</PromptDialogProvider>,
);
await act(async () => {
screen.getByText("Open Prompt").click();
});
const input = screen.getByDisplayValue("John");
await act(async () => {
fireEvent.change(input, { target: { value: "Enter User" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter" });
});
expect(screen.getByTestId("result").textContent).toBe("Enter User");
});
});
+203
View File
@@ -0,0 +1,203 @@
import { afterEach, describe, expect, it } from "vite-plus/test";
import { useAIStore } from "./store";
// ---------------------------------------------------------------------------
// Reset store between tests
// ---------------------------------------------------------------------------
afterEach(() => {
useAIStore.getState().reset();
});
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
describe("AI Store — initial state", () => {
it("starts with default values", () => {
const state = useAIStore.getState();
expect(state.enabled).toBe(false);
expect(state.provider).toBe("openai");
expect(state.model).toBe("");
expect(state.apiKey).toBe("");
expect(state.baseURL).toBe("");
expect(state.testStatus).toBe("unverified");
});
});
// ---------------------------------------------------------------------------
// set()
// ---------------------------------------------------------------------------
describe("AI Store — set()", () => {
it("updates provider", () => {
useAIStore.getState().set((draft) => {
draft.provider = "anthropic";
});
expect(useAIStore.getState().provider).toBe("anthropic");
});
it("updates model and apiKey", () => {
useAIStore.getState().set((draft) => {
draft.model = "gpt-4";
draft.apiKey = "sk-test-key";
});
expect(useAIStore.getState().model).toBe("gpt-4");
expect(useAIStore.getState().apiKey).toBe("sk-test-key");
});
it("resets testStatus to unverified when provider changes", () => {
// First set testStatus to success
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
// Now change provider — should reset
useAIStore.getState().set((draft) => {
draft.provider = "gemini";
});
expect(useAIStore.getState().testStatus).toBe("unverified");
});
it("resets testStatus to unverified when model changes", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
useAIStore.getState().set((draft) => {
draft.model = "new-model";
});
expect(useAIStore.getState().testStatus).toBe("unverified");
});
it("resets testStatus to unverified when apiKey changes", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
useAIStore.getState().set((draft) => {
draft.apiKey = "new-key";
});
expect(useAIStore.getState().testStatus).toBe("unverified");
});
it("resets testStatus to unverified when baseURL changes", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
useAIStore.getState().set((draft) => {
draft.baseURL = "https://new-url.com";
});
expect(useAIStore.getState().testStatus).toBe("unverified");
});
it("disables when config changes", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
useAIStore.getState().setEnabled(true);
expect(useAIStore.getState().enabled).toBe(true);
// Change provider — should disable
useAIStore.getState().set((draft) => {
draft.provider = "ollama";
});
expect(useAIStore.getState().enabled).toBe(false);
});
it("does NOT reset testStatus when non-config fields change", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
// Changing testStatus itself shouldn't trigger the reset logic
// (since provider/model/apiKey/baseURL didn't change)
expect(useAIStore.getState().testStatus).toBe("success");
});
});
// ---------------------------------------------------------------------------
// canEnable()
// ---------------------------------------------------------------------------
describe("AI Store — canEnable()", () => {
it("returns false when testStatus is unverified", () => {
expect(useAIStore.getState().canEnable()).toBe(false);
});
it("returns false when testStatus is failure", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "failure";
});
expect(useAIStore.getState().canEnable()).toBe(false);
});
it("returns true when testStatus is success", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
expect(useAIStore.getState().canEnable()).toBe(true);
});
});
// ---------------------------------------------------------------------------
// setEnabled()
// ---------------------------------------------------------------------------
describe("AI Store — setEnabled()", () => {
it("enables when testStatus is success", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
useAIStore.getState().setEnabled(true);
expect(useAIStore.getState().enabled).toBe(true);
});
it("refuses to enable when testStatus is not success", () => {
useAIStore.getState().setEnabled(true);
expect(useAIStore.getState().enabled).toBe(false);
});
it("can disable regardless of testStatus", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "success";
});
useAIStore.getState().setEnabled(true);
expect(useAIStore.getState().enabled).toBe(true);
useAIStore.getState().setEnabled(false);
expect(useAIStore.getState().enabled).toBe(false);
});
it("refuses to enable after failure", () => {
useAIStore.getState().set((draft) => {
draft.testStatus = "failure";
});
useAIStore.getState().setEnabled(true);
expect(useAIStore.getState().enabled).toBe(false);
});
});
// ---------------------------------------------------------------------------
// reset()
// ---------------------------------------------------------------------------
describe("AI Store — reset()", () => {
it("restores all state to initial values", () => {
useAIStore.getState().set((draft) => {
draft.provider = "anthropic";
draft.model = "claude-3";
draft.apiKey = "sk-key";
draft.baseURL = "https://api.anthropic.com";
draft.testStatus = "success";
});
useAIStore.getState().setEnabled(true);
useAIStore.getState().reset();
const state = useAIStore.getState();
expect(state.enabled).toBe(false);
expect(state.provider).toBe("openai");
expect(state.model).toBe("");
expect(state.apiKey).toBe("");
expect(state.baseURL).toBe("");
expect(state.testStatus).toBe("unverified");
});
});
+8 -1
View File
@@ -2,7 +2,13 @@ import { apiKeyClient } from "@better-auth/api-key/client";
import { dashClient } from "@better-auth/infra/client";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
import { genericOAuthClient, inferAdditionalFields, twoFactorClient, usernameClient } from "better-auth/client/plugins";
import {
adminClient,
genericOAuthClient,
inferAdditionalFields,
twoFactorClient,
usernameClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import type { auth } from "./config";
@@ -11,6 +17,7 @@ const getAuthClient = () => {
return createAuthClient({
plugins: [
dashClient(),
adminClient(),
apiKeyClient(),
usernameClient(),
twoFactorClient({
+31 -4
View File
@@ -1,4 +1,3 @@
import type { GenericOAuthConfig } from "better-auth/plugins";
import type { JWTPayload } from "jose";
import { apiKey } from "@better-auth/api-key";
@@ -7,7 +6,7 @@ import { dash } from "@better-auth/infra";
import { oauthProvider } from "@better-auth/oauth-provider";
import { BetterAuthError, betterAuth } from "better-auth";
import { verifyAccessToken } from "better-auth/oauth2";
import { jwt, openAPI } from "better-auth/plugins";
import { admin, jwt, openAPI, type GenericOAuthConfig } from "better-auth/plugins";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { twoFactor } from "better-auth/plugins/two-factor";
import { username } from "better-auth/plugins/username";
@@ -124,7 +123,7 @@ const getAuthConfig = () => {
emailAndPassword: {
enabled: !env.FLAG_DISABLE_EMAIL_AUTH,
autoSignIn: true,
minPasswordLength: 6,
minPasswordLength: 8,
maxPasswordLength: 64,
requireEmailVerification: false,
disableSignUp: env.FLAG_DISABLE_SIGNUPS || env.FLAG_DISABLE_EMAIL_AUTH,
@@ -175,7 +174,7 @@ const getAuthConfig = () => {
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github"],
trustedProviders: ["google", "github", "linkedin"],
},
},
@@ -249,10 +248,38 @@ const getAuthConfig = () => {
};
},
},
linkedin: {
enabled: !!env.LINKEDIN_CLIENT_ID && !!env.LINKEDIN_CLIENT_SECRET,
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
clientId: env.LINKEDIN_CLIENT_ID!,
clientSecret: env.LINKEDIN_CLIENT_SECRET!,
mapProfileToUser: async (profile) => {
if (!profile.email) {
throw new BetterAuthError(
"LinkedIn provider did not return an email address. This is required for user creation.",
{ cause: "EMAIL_REQUIRED" },
);
}
const username = profile.email.split("@")[0];
const name = profile.name ?? username;
return {
name,
email: profile.email,
image: profile.picture,
username,
displayUsername: username,
emailVerified: true,
};
},
},
},
plugins: [
jwt(),
admin(),
openAPI(),
genericOAuth({ config: authConfigs }),
twoFactor({ issuer: "Reactive Resume" }),
+1 -1
View File
@@ -7,6 +7,6 @@ export type AuthSession = {
user: typeof auth.$Infer.Session.user;
};
const authProviderSchema = z.enum(["credential", "google", "github", "custom"]);
const authProviderSchema = z.enum(["credential", "google", "github", "linkedin", "custom"]);
export type AuthProvider = z.infer<typeof authProviderSchema>;
+5
View File
@@ -20,6 +20,10 @@ export const user = pg.pgTable(
displayUsername: pg.text("display_username").notNull().unique(),
twoFactorEnabled: pg.boolean("two_factor_enabled").notNull().default(false),
lastActiveAt: pg.timestamp("last_active_at", { withTimezone: true }),
role: pg.text("role").default("user"),
banned: pg.boolean("banned").default(false),
banReason: pg.text("ban_reason"),
banExpires: pg.timestamp("ban_expires", { precision: 6, withTimezone: true }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
@@ -41,6 +45,7 @@ export const session = pg.pgTable(
token: pg.text("token").notNull().unique(),
ipAddress: pg.text("ip_address"),
userAgent: pg.text("user_agent"),
impersonatedBy: pg.text("impersonated_by"),
userId: pg
.uuid("user_id")
.notNull()
+327
View File
@@ -0,0 +1,327 @@
import { describe, expect, it } from "vite-plus/test";
import { resumeDataSchema } from "@/schema/resume/data";
import { JSONResumeImporter } from "./json-resume";
const importer = new JSONResumeImporter();
function makeMinimalJsonResume(overrides?: Record<string, unknown>) {
return {
basics: {
name: "John Doe",
label: "Software Engineer",
email: "john@example.com",
phone: "+1-555-0100",
summary: "A passionate developer.",
location: { city: "San Francisco", region: "CA", countryCode: "US" },
url: "https://johndoe.com",
profiles: [{ network: "GitHub", username: "johndoe", url: "https://github.com/johndoe" }],
},
work: [
{
name: "Acme Corp",
position: "Senior Developer",
startDate: "2020-01",
endDate: "2024-06",
summary: "Led a team of 5.",
highlights: ["Built CI/CD pipeline", "Reduced build time by 50%"],
},
],
education: [
{
institution: "MIT",
studyType: "Bachelor",
area: "Computer Science",
score: "3.9",
startDate: "2016-09",
endDate: "2020-05",
},
],
skills: [{ name: "TypeScript", level: "Advanced", keywords: ["React", "Node.js"] }],
languages: [{ language: "English", fluency: "Native" }],
...overrides,
};
}
describe("JSONResumeImporter", () => {
describe("parse", () => {
it("parses a valid JSON Resume and produces valid ResumeData", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
expect(resumeDataSchema.safeParse(result).success).toBe(true);
});
it("throws on invalid JSON", () => {
expect(() => importer.parse("not json")).toThrow();
});
});
describe("convert - basics", () => {
it("maps name and headline", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
expect(result.basics.name).toBe("John Doe");
expect(result.basics.headline).toBe("Software Engineer");
});
it("maps email and phone", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
expect(result.basics.email).toBe("john@example.com");
expect(result.basics.phone).toBe("+1-555-0100");
});
it("formats location from object to string", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
expect(result.basics.location).toBe("San Francisco, CA, US");
});
it("maps website URL", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
expect(result.basics.website.url).toBe("https://johndoe.com");
});
});
describe("convert - summary", () => {
it("wraps summary in HTML", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
expect(result.summary.content).toBe("<p>A passionate developer.</p>");
expect(result.summary.hidden).toBe(false);
});
it("handles missing summary", () => {
const data = makeMinimalJsonResume();
delete (data.basics as Record<string, unknown>).summary;
const result = importer.parse(JSON.stringify(data));
expect(result.summary.content).toBe("");
});
});
describe("convert - experience", () => {
it("maps work to experience items", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
expect(result.sections.experience.items).toHaveLength(1);
const exp = result.sections.experience.items[0];
expect(exp.company).toBe("Acme Corp");
expect(exp.position).toBe("Senior Developer");
expect(exp.period).toBe("January 2020 - June 2024");
});
it("converts summary+highlights to HTML description", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
const exp = result.sections.experience.items[0];
expect(exp.description).toContain("<p>Led a team of 5.</p>");
expect(exp.description).toContain("<li>Built CI/CD pipeline</li>");
});
it("filters out entries without name or position", () => {
const data = makeMinimalJsonResume({ work: [{ location: "Nowhere" }] });
const result = importer.parse(JSON.stringify(data));
expect(result.sections.experience.items).toHaveLength(0);
});
});
describe("convert - education", () => {
it("maps education items", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
const edu = result.sections.education.items[0];
expect(edu.school).toBe("MIT");
expect(edu.degree).toBe("Bachelor in Computer Science");
expect(edu.grade).toBe("3.9");
});
});
describe("convert - skills", () => {
it("maps skills with level parsing", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
const skill = result.sections.skills.items[0];
expect(skill.name).toBe("TypeScript");
expect(skill.level).toBe(4); // "Advanced" maps to 4
expect(skill.keywords).toEqual(["React", "Node.js"]);
});
});
describe("convert - languages", () => {
it("maps languages with fluency level", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
const lang = result.sections.languages.items[0];
expect(lang.language).toBe("English");
expect(lang.level).toBe(5); // "Native" maps to 5
});
});
describe("convert - profiles", () => {
it("maps profiles with network icons", () => {
const result = importer.parse(JSON.stringify(makeMinimalJsonResume()));
const profile = result.sections.profiles.items[0];
expect(profile.network).toBe("GitHub");
expect(profile.icon).toBe("github-logo");
expect(profile.username).toBe("johndoe");
});
});
describe("convert - awards", () => {
it("maps awards with formatted dates", () => {
const data = makeMinimalJsonResume({
awards: [{ title: "Best Paper", awarder: "IEEE", date: "2023-06-15", summary: "Great work" }],
});
const result = importer.parse(JSON.stringify(data));
const award = result.sections.awards.items[0];
expect(award.title).toBe("Best Paper");
expect(award.awarder).toBe("IEEE");
expect(award.date).toBe("June 15, 2023");
expect(award.description).toBe("<p>Great work</p>");
});
});
describe("convert - volunteer", () => {
it("maps volunteer items with period and description", () => {
const data = makeMinimalJsonResume({
volunteer: [
{
organization: "Red Cross",
position: "Volunteer",
startDate: "2022-01",
endDate: "2023-06",
summary: "Helped with logistics",
highlights: ["Organized events"],
url: "https://redcross.org",
},
],
});
const result = importer.parse(JSON.stringify(data));
const vol = result.sections.volunteer.items[0];
expect(vol.organization).toBe("Red Cross");
expect(vol.period).toBe("January 2022 - June 2023");
expect(vol.description).toContain("Helped with logistics");
expect(vol.description).toContain("Organized events");
});
it("filters out volunteer items without organization", () => {
const data = makeMinimalJsonResume({
volunteer: [
{ organization: "Valid Org", position: "Vol" },
{ position: "No Org" }, // missing organization
],
});
const result = importer.parse(JSON.stringify(data));
expect(result.sections.volunteer.items).toHaveLength(1);
});
});
describe("convert - references", () => {
it("maps references with name and reference text", () => {
const data = makeMinimalJsonResume({
references: [{ name: "John Manager", reference: "Great developer and team player" }],
});
const result = importer.parse(JSON.stringify(data));
const ref = result.sections.references.items[0];
expect(ref.name).toBe("John Manager");
expect(ref.description).toBe("<p>Great developer and team player</p>");
});
it("handles references without reference text", () => {
const data = makeMinimalJsonResume({
references: [{ name: "Jane CTO" }],
});
const result = importer.parse(JSON.stringify(data));
expect(result.sections.references.items[0].description).toBe("");
});
it("filters references without name or reference", () => {
const data = makeMinimalJsonResume({
references: [
{ name: "Valid" },
{}, // no name or reference
],
});
const result = importer.parse(JSON.stringify(data));
expect(result.sections.references.items).toHaveLength(1);
});
});
describe("convert - publications", () => {
it("maps publications with formatted date", () => {
const data = makeMinimalJsonResume({
publications: [
{
name: "My Paper",
publisher: "IEEE",
releaseDate: "2024-03-15",
summary: "Research findings",
url: "https://doi.org/paper",
},
],
});
const result = importer.parse(JSON.stringify(data));
const pub = result.sections.publications.items[0];
expect(pub.title).toBe("My Paper");
expect(pub.publisher).toBe("IEEE");
expect(pub.date).toBe("March 15, 2024");
});
});
describe("convert - certifications", () => {
it("maps certificates with issuer and date", () => {
const data = makeMinimalJsonResume({
certificates: [{ name: "AWS Certified", issuer: "Amazon", date: "2023-06" }],
});
const result = importer.parse(JSON.stringify(data));
const cert = result.sections.certifications.items[0];
expect(cert.title).toBe("AWS Certified");
expect(cert.issuer).toBe("Amazon");
expect(cert.date).toBe("June 2023");
});
});
describe("convert - interests", () => {
it("maps interests with keywords", () => {
const data = makeMinimalJsonResume({
interests: [{ name: "Gaming", keywords: ["RPG", "Strategy"] }],
});
const result = importer.parse(JSON.stringify(data));
const interest = result.sections.interests.items[0];
expect(interest.name).toBe("Gaming");
expect(interest.keywords).toEqual(["RPG", "Strategy"]);
});
});
describe("convert - empty sections", () => {
it("handles resume with no optional sections", () => {
const minimal = { basics: { name: "Jane" } };
const result = importer.parse(JSON.stringify(minimal));
expect(resumeDataSchema.safeParse(result).success).toBe(true);
expect(result.basics.name).toBe("Jane");
});
});
describe("error handling", () => {
it("throws on invalid JSON", () => {
expect(() => importer.parse("not json")).toThrow();
});
it("handles missing basics.location gracefully", () => {
const data = { basics: { name: "Jane" } };
const result = importer.parse(JSON.stringify(data));
expect(result.basics.location).toBe("");
});
it("allows work items with position but no name (falls back to position)", () => {
const data = makeMinimalJsonResume();
// Work item with position but no name — filter passes because position is truthy
// The importer maps name -> company, so company will be empty
// This is a known limitation: the filter checks (name || position) but company requires min(1)
// The resumeDataSchema.parse() will reject it
(data as any).work.push({ name: "", position: "Ghost Dev", startDate: "2020-01" });
expect(() => importer.parse(JSON.stringify(data))).toThrow();
});
it("filters profiles without network name", () => {
const data = makeMinimalJsonResume();
(data as any).basics.profiles = [
{ network: "GitHub", username: "jane" },
{ username: "no-network" }, // no network
];
const result = importer.parse(JSON.stringify(data));
expect(result.sections.profiles.items).toHaveLength(1);
});
});
});
+5 -190
View File
@@ -1,9 +1,12 @@
import { flattenError, ZodError, z } from "zod";
import type { IconName } from "@/schema/icons";
import { defaultResumeData, type ResumeData, resumeDataSchema } from "@/schema/resume/data";
import { formatPeriod, formatSingleDate } from "@/utils/date";
import { arrayToHtmlList, toHtmlDescription } from "@/utils/html";
import { parseLevel } from "@/utils/level";
import { getNetworkIcon } from "@/utils/network-icons";
import { generateId } from "@/utils/string";
import { createUrl } from "@/utils/url";
// Custom ISO 8601 date pattern that allows partial dates (year only, year-month, or full date)
const iso8601 = z
@@ -153,138 +156,6 @@ const jsonResumeSchema = z.looseObject({
type JSONResume = z.infer<typeof jsonResumeSchema>;
// Helper function to format date period from start and end dates
function formatPeriod(startDate?: string, endDate?: string): string {
if (!startDate && !endDate) return "";
if (!startDate) return endDate || "";
if (!endDate) return `${startDate} - Present`;
// Format dates to be more readable
const formatDate = (date: string): string => {
// Handle YYYY-MM-DD, YYYY-MM, or YYYY formats
const parts = date.split("-");
if (parts.length === 3) {
// YYYY-MM-DD
const [year, month] = parts;
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return `${monthNames[parseInt(month, 10) - 1]} ${year}`;
}
if (parts.length === 2) {
// YYYY-MM
const [year, month] = parts;
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return `${monthNames[parseInt(month, 10) - 1]} ${year}`;
}
// YYYY
return date;
};
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
}
// Helper function to format a single date
function formatSingleDate(date?: string): string {
if (!date) return "";
// Format dates to be more readable
const parts = date.split("-");
if (parts.length === 3) {
// YYYY-MM-DD
const [year, month, day] = parts;
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return `${monthNames[parseInt(month, 10) - 1]} ${day}, ${year}`;
}
if (parts.length === 2) {
// YYYY-MM
const [year, month] = parts;
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return `${monthNames[parseInt(month, 10) - 1]} ${year}`;
}
// YYYY
return date;
}
// Helper function to convert text and highlights to HTML
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("");
}
// Helper function to convert array to HTML list
function arrayToHtmlList(items?: string[]): string {
if (!items || items.length === 0) return "";
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
}
// Helper function to format location object to string
function formatLocation(location?: {
address?: string;
@@ -303,62 +174,6 @@ function formatLocation(location?: {
return parts.join(", ");
}
// Helper function to map network name to icon
function getNetworkIcon(network?: string): IconName {
if (!network) return "star";
const networkLower = network.toLowerCase();
if (networkLower.includes("github")) return "github-logo";
if (networkLower.includes("linkedin")) return "linkedin-logo";
if (networkLower.includes("twitter") || networkLower.includes("x.com")) return "twitter-logo";
if (networkLower.includes("facebook")) return "facebook-logo";
if (networkLower.includes("instagram")) return "instagram-logo";
if (networkLower.includes("youtube")) return "youtube-logo";
if (networkLower.includes("stackoverflow") || networkLower.includes("stack-overflow")) return "stack-overflow-logo";
if (networkLower.includes("medium")) return "medium-logo";
if (networkLower.includes("dev.to") || networkLower.includes("devto")) return "code";
if (networkLower.includes("dribbble")) return "dribbble-logo";
if (networkLower.includes("behance")) return "behance-logo";
if (networkLower.includes("gitlab")) return "git-branch";
if (networkLower.includes("bitbucket")) return "code";
if (networkLower.includes("codepen")) return "code";
return "star";
}
// Helper function to parse skill/language level to number (0-5)
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;
}
// Helper function to create URL object
function createUrl(url?: string, label?: string): { url: string; label: string } {
if (!url) return { url: "", label: "" };
return { url, label: label || url };
}
export class JSONResumeImporter {
convert(jsonResume: JSONResume): ResumeData {
const result: ResumeData = {
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vite-plus/test";
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
import { ReactiveResumeJSONImporter } from "./reactive-resume-json";
const importer = new ReactiveResumeJSONImporter();
describe("ReactiveResumeJSONImporter", () => {
it("parses valid ResumeData JSON", () => {
const result = importer.parse(JSON.stringify(defaultResumeData));
expect(resumeDataSchema.safeParse(result).success).toBe(true);
});
it("throws on invalid JSON", () => {
expect(() => importer.parse("not json")).toThrow();
});
it("throws on data that doesn't match schema", () => {
expect(() => importer.parse(JSON.stringify({ invalid: true }))).toThrow();
});
it("normalizes missing layout sections", () => {
const data = {
...defaultResumeData,
metadata: {
...defaultResumeData.metadata,
layout: {
...defaultResumeData.metadata.layout,
pages: [{ fullWidth: false, main: ["experience"], sidebar: [] }],
},
},
};
const result = importer.parse(JSON.stringify(data));
// Should add missing built-in section IDs to main
const allSectionIds = result.metadata.layout.pages.flatMap((p) => [...p.main, ...p.sidebar]);
expect(allSectionIds).toContain("experience");
expect(allSectionIds).toContain("education");
expect(allSectionIds).toContain("skills");
});
it("handles empty layout pages by creating default page", () => {
const data = {
...defaultResumeData,
metadata: {
...defaultResumeData.metadata,
layout: {
...defaultResumeData.metadata.layout,
pages: [],
},
},
};
const result = importer.parse(JSON.stringify(data));
expect(result.metadata.layout.pages).toHaveLength(1);
expect(result.metadata.layout.pages[0].main.length).toBeGreaterThan(0);
});
it("preserves complete layout without modification", () => {
const result = importer.parse(JSON.stringify(defaultResumeData));
expect(result.metadata.layout).toEqual(defaultResumeData.metadata.layout);
});
});
@@ -0,0 +1,808 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import type { SectionItem } from "@/schema/resume/data";
import { resumeDataSchema } from "@/schema/resume/data";
import { ReactiveResumeV4JSONImporter } from "./reactive-resume-v4-json";
// Mock generateId for deterministic output
let idCounter = 0;
vi.mock("@/utils/string", () => ({
generateId: () => `mock-id-${++idCounter}`,
slugify: (str: string) => str.toLowerCase().replace(/\s+/g, "-"),
getInitials: (str: string) =>
str
.split(" ")
.map((s) => s[0])
.join(""),
toUsername: (str: string) => str.toLowerCase().replace(/\s+/g, ""),
stripHtml: (str: string) => str.replace(/<[^>]*>/g, ""),
}));
beforeEach(() => {
idCounter = 0;
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const importer = new ReactiveResumeV4JSONImporter();
function makeMinimalV4Resume(overrides?: Record<string, unknown>) {
return {
basics: {
name: "Jane Doe",
headline: "Developer",
email: "jane@example.com",
phone: "+1234567890",
location: "New York, NY",
url: { label: "Website", href: "https://jane.dev" },
customFields: [],
picture: {
url: "https://example.com/photo.jpg",
size: 80,
aspectRatio: 1,
borderRadius: 10,
effects: { hidden: false, border: true, grayscale: false },
},
},
sections: {
summary: {
name: "Summary",
columns: 1,
separateLinks: false,
visible: true,
id: "summary",
content: "<p>A developer</p>",
},
awards: { name: "Awards", columns: 1, separateLinks: false, visible: true, id: "awards", items: [] },
certifications: {
name: "Certifications",
columns: 1,
separateLinks: false,
visible: true,
id: "certifications",
items: [],
},
education: { name: "Education", columns: 1, separateLinks: false, visible: true, id: "education", items: [] },
experience: { name: "Experience", columns: 1, separateLinks: false, visible: true, id: "experience", items: [] },
volunteer: { name: "Volunteer", columns: 1, separateLinks: false, visible: true, id: "volunteer", items: [] },
interests: { name: "Interests", columns: 1, separateLinks: false, visible: true, id: "interests", items: [] },
languages: { name: "Languages", columns: 1, separateLinks: false, visible: true, id: "languages", items: [] },
profiles: { name: "Profiles", columns: 1, separateLinks: false, visible: true, id: "profiles", items: [] },
projects: { name: "Projects", columns: 1, separateLinks: false, visible: true, id: "projects", items: [] },
publications: {
name: "Publications",
columns: 1,
separateLinks: false,
visible: true,
id: "publications",
items: [],
},
references: { name: "References", columns: 1, separateLinks: false, visible: true, id: "references", items: [] },
skills: { name: "Skills", columns: 1, separateLinks: false, visible: true, id: "skills", items: [] },
},
metadata: {
template: "onyx",
layout: [
[
["experience", "education"],
["skills", "languages"],
],
],
css: { value: "", visible: false },
page: { margin: 14, format: "a4" as const, options: { breakLine: true, pageNumbers: true } },
theme: { background: "#ffffff", text: "#000000", primary: "#dc2626" },
typography: {
font: { family: "IBM Plex Serif", subset: "latin", variants: ["regular"], size: 14.67 },
lineHeight: 1.5,
hideIcons: false,
underlineLinks: true,
},
notes: "",
},
...overrides,
};
}
// ---------------------------------------------------------------------------
// Basic parsing
// ---------------------------------------------------------------------------
describe("ReactiveResumeV4JSONImporter", () => {
describe("basic parsing", () => {
it("parses a minimal V4 resume and produces valid ResumeData", () => {
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
expect(resumeDataSchema.safeParse(result).success).toBe(true);
});
it("maps basics fields correctly", () => {
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
expect(result.basics.name).toBe("Jane Doe");
expect(result.basics.headline).toBe("Developer");
expect(result.basics.email).toBe("jane@example.com");
expect(result.basics.phone).toBe("+1234567890");
expect(result.basics.location).toBe("New York, NY");
expect(result.basics.website.url).toBe("https://jane.dev");
expect(result.basics.website.label).toBe("Website");
});
it("maps picture fields correctly", () => {
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
expect(result.picture.url).toBe("https://example.com/photo.jpg");
expect(result.picture.size).toBe(80);
expect(result.picture.aspectRatio).toBe(1);
expect(result.picture.borderRadius).toBe(10);
expect(result.picture.hidden).toBe(false);
expect(result.picture.borderWidth).toBe(1); // border effect was true
});
it("maps summary correctly", () => {
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
expect(result.summary.title).toBe("Summary");
expect(result.summary.content).toBe("<p>A developer</p>");
expect(result.summary.hidden).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Section items
// ---------------------------------------------------------------------------
describe("section items", () => {
it("transforms experience items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).experience.items = [
{
id: "exp-1",
visible: true,
company: "Acme Corp",
position: "Engineer",
location: "NYC",
date: "2020 - 2022",
summary: "<p>Built things</p>",
url: { label: "Acme", href: "https://acme.com" },
},
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.experience.items).toHaveLength(1);
const exp = result.sections.experience.items[0];
expect(exp.company).toBe("Acme Corp");
expect(exp.position).toBe("Engineer");
expect(exp.location).toBe("NYC");
expect(exp.period).toBe("2020 - 2022");
expect(exp.description).toBe("<p>Built things</p>");
expect(exp.website.url).toBe("https://acme.com");
});
it("transforms education items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).education.items = [
{
id: "edu-1",
visible: true,
institution: "MIT",
studyType: "BSc",
area: "Computer Science",
score: "4.0",
date: "2016 - 2020",
summary: "<p>Graduated</p>",
url: { label: "MIT", href: "https://mit.edu" },
},
];
const result = importer.parse(JSON.stringify(v4));
const edu = result.sections.education.items[0];
expect(edu.school).toBe("MIT");
expect(edu.degree).toBe("BSc");
expect(edu.area).toBe("Computer Science");
expect(edu.grade).toBe("4.0");
expect(edu.period).toBe("2016 - 2020");
});
it("transforms skill items with level clamping", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).skills.items = [
{ id: "s1", visible: true, name: "TypeScript", description: "Advanced", level: 4, keywords: ["frontend"] },
{ id: "s2", visible: true, name: "Rust", description: "Beginner", level: 10, keywords: [] }, // level > 5
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.skills.items).toHaveLength(2);
expect(result.sections.skills.items[0].name).toBe("TypeScript");
expect(result.sections.skills.items[0].proficiency).toBe("Advanced");
expect(result.sections.skills.items[0].level).toBe(4);
expect(result.sections.skills.items[1].level).toBe(5); // clamped to 5
});
it("transforms language items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).languages.items = [
{ id: "l1", visible: true, name: "English", description: "Native", level: 5 },
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.languages.items[0].language).toBe("English");
expect(result.sections.languages.items[0].fluency).toBe("Native");
});
it("transforms profile items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).profiles.items = [
{
id: "p1",
visible: true,
network: "GitHub",
username: "janedoe",
icon: "github",
url: { label: "GitHub", href: "https://github.com/janedoe" },
},
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.profiles.items[0].network).toBe("GitHub");
expect(result.sections.profiles.items[0].username).toBe("janedoe");
expect(result.sections.profiles.items[0].icon).toBe("github");
});
it("transforms award items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).awards.items = [
{
id: "a1",
visible: true,
title: "Best Developer",
awarder: "Company",
date: "2023",
summary: "<p>Won it</p>",
},
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.awards.items[0].title).toBe("Best Developer");
expect(result.sections.awards.items[0].awarder).toBe("Company");
});
it("transforms certification items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).certifications.items = [
{ id: "c1", visible: true, name: "AWS Certified", issuer: "AWS", date: "2023" },
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.certifications.items[0].title).toBe("AWS Certified");
expect(result.sections.certifications.items[0].issuer).toBe("AWS");
});
it("transforms publication items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).publications.items = [
{ id: "pub1", visible: true, name: "My Paper", publisher: "IEEE", date: "2024", summary: "<p>Research</p>" },
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.publications.items[0].title).toBe("My Paper");
expect(result.sections.publications.items[0].publisher).toBe("IEEE");
});
it("transforms volunteer items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).volunteer.items = [
{
id: "v1",
visible: true,
organization: "Red Cross",
position: "Helper",
location: "NYC",
date: "2023",
summary: "<p>Helped</p>",
},
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.volunteer.items[0].organization).toBe("Red Cross");
expect(result.sections.volunteer.items[0].location).toBe("NYC");
});
it("transforms reference items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).references.items = [
{ id: "r1", visible: true, name: "John Manager", description: "CTO", summary: "<p>Great developer</p>" },
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.references.items[0].name).toBe("John Manager");
expect(result.sections.references.items[0].position).toBe("CTO");
expect(result.sections.references.items[0].description).toBe("<p>Great developer</p>");
});
it("transforms interest items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).interests.items = [
{ id: "i1", visible: true, name: "Gaming", keywords: ["RPG", "Strategy"] },
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.interests.items[0].name).toBe("Gaming");
expect(result.sections.interests.items[0].keywords).toEqual(["RPG", "Strategy"]);
});
it("transforms project items using summary over description", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).projects.items = [
{
id: "proj1",
visible: true,
name: "My App",
description: "desc",
date: "2024",
summary: "<p>summary</p>",
url: { label: "", href: "" },
},
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.projects.items[0].description).toBe("<p>summary</p>");
});
it("falls back to description when summary is missing for projects", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).projects.items = [
{ id: "proj1", visible: true, name: "My App", description: "desc text", date: "2024" },
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.projects.items[0].description).toBe("desc text");
});
});
// ---------------------------------------------------------------------------
// Filtering
// ---------------------------------------------------------------------------
describe("filtering", () => {
it("filters out items with empty required fields", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).experience.items = [
{ id: "e1", visible: true, company: "Acme", position: "Dev" },
{ id: "e2", visible: true, company: "", position: "Dev" }, // empty company
{ id: "e3", visible: true, position: "Dev" }, // missing company
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.experience.items).toHaveLength(1);
expect(result.sections.experience.items[0].company).toBe("Acme");
});
it("filters out profiles without network name", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).profiles.items = [
{ id: "p1", visible: true, network: "GitHub", username: "jane" },
{ id: "p2", visible: true, network: "", username: "jane" },
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.profiles.items).toHaveLength(1);
});
it("filters out skills without name", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).skills.items = [
{ id: "s1", visible: true, name: "TypeScript" },
{ id: "s2", visible: true, name: "" },
];
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.skills.items).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// Visibility mapping
// ---------------------------------------------------------------------------
describe("visibility mapping", () => {
it("maps visible: false to hidden: true", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).experience.items = [{ id: "e1", visible: false, company: "Acme", position: "Dev" }];
(v4.sections as any).experience.visible = false;
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.experience.hidden).toBe(true);
expect(result.sections.experience.items[0].hidden).toBe(true);
});
it("maps visible: true to hidden: false", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).awards.visible = true;
const result = importer.parse(JSON.stringify(v4));
expect(result.sections.awards.hidden).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
describe("metadata", () => {
it("maps theme colors to rgba format", () => {
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
expect(result.metadata.design.colors.primary).toMatch(/^rgba\(/);
expect(result.metadata.design.colors.text).toMatch(/^rgba\(/);
expect(result.metadata.design.colors.background).toMatch(/^rgba\(/);
});
it("maps CSS settings", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.css = { value: "body { color: red; }", visible: true };
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.css.enabled).toBe(true);
expect(result.metadata.css.value).toBe("body { color: red; }");
});
it("maps page format", () => {
const v4 = makeMinimalV4Resume();
(v4.metadata.page.format as "a4" | "letter") = "letter";
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.page.format).toBe("letter");
});
it("maps font family and font size (px to pt conversion)", () => {
const result = importer.parse(JSON.stringify(makeMinimalV4Resume()));
expect(result.metadata.typography.body.fontFamily).toBe("IBM Plex Serif");
// 14.67px * 0.75 = 11.0025pt
expect(result.metadata.typography.body.fontSize).toBeCloseTo(11, 0);
});
it("maps hideIcons setting", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.typography.hideIcons = true;
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.page.hideIcons).toBe(true);
});
it("defaults to onyx template for invalid template names", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.template = "nonexistent-template";
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.template).toBe("onyx");
});
it("preserves valid template names", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.template = "pikachu";
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.template).toBe("pikachu");
});
it("maps notes", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.notes = "Some notes here";
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.notes).toBe("Some notes here");
});
});
// ---------------------------------------------------------------------------
// Layout
// ---------------------------------------------------------------------------
describe("layout", () => {
it("converts V4 layout pages to new format", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.layout = [
[["experience", "education"], ["skills"]],
[["projects"], []],
];
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.layout.pages).toHaveLength(2);
expect(result.metadata.layout.pages[0].main).toContain("experience");
expect(result.metadata.layout.pages[0].sidebar).toContain("skills");
expect(result.metadata.layout.pages[1].main).toContain("projects");
expect(result.metadata.layout.pages[1].fullWidth).toBe(true); // empty sidebar
});
it("strips 'custom.' prefix from layout section IDs", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.layout = [[["experience", "custom.abc123"], ["skills"]]];
(v4.sections as any).custom = {
abc123: {
name: "Custom Section",
columns: 1,
separateLinks: false,
visible: true,
id: "abc123",
items: [],
},
};
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.layout.pages[0].main).toContain("abc123");
expect(result.metadata.layout.pages[0].main).not.toContain("custom.abc123");
});
it("removes summary from layout columns (handled separately)", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.layout = [[["summary", "experience"], ["skills"]]];
const result = importer.parse(JSON.stringify(v4));
// Summary should be re-added at the front when visible
const mainSections = result.metadata.layout.pages[0].main;
expect(mainSections[0]).toBe("summary");
expect(mainSections.filter((s) => s === "summary")).toHaveLength(1);
});
it("adds summary to front of first page when visible with content", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.layout = [[["experience"], ["skills"]]];
(v4.sections as any).summary.visible = true;
(v4.sections as any).summary.content = "<p>Summary text</p>";
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.layout.pages[0].main[0]).toBe("summary");
});
it("does NOT add summary to layout when it is not visible", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.layout = [[["experience"], ["skills"]]];
(v4.sections as any).summary.visible = false;
(v4.sections as any).summary.content = "<p>Summary text</p>";
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.layout.pages[0].main).not.toContain("summary");
});
});
// ---------------------------------------------------------------------------
// Custom sections
// ---------------------------------------------------------------------------
describe("custom sections", () => {
it("transforms custom sections to experience-typed custom sections", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).custom = {
"custom-1": {
name: "Freelance Work",
columns: 2,
separateLinks: false,
visible: true,
id: "custom-1",
items: [
{
id: "ci1",
visible: true,
name: "Project A",
description: "Lead Dev",
date: "2023",
location: "Remote",
summary: "<p>Details</p>",
},
],
},
};
const result = importer.parse(JSON.stringify(v4));
const section = result.customSections[0];
const item = section.items[0] as SectionItem<"experience">;
expect(result.customSections).toHaveLength(1);
expect(section.title).toBe("Freelance Work");
expect(section.type).toBe("experience");
expect(section.columns).toBe(2);
expect(section.items).toHaveLength(1);
expect(item.company).toBe("Project A");
expect(item.position).toBe("Lead Dev");
});
it("handles custom section items without name by using index", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).custom = {
"custom-1": {
name: "Other",
columns: 1,
separateLinks: false,
visible: true,
id: "custom-1",
items: [{ id: "ci1", visible: true, description: "Something" }],
},
};
const result = importer.parse(JSON.stringify(v4));
const section = result.customSections[0];
const item = section.items[0] as SectionItem<"experience">;
expect(item.company).toBe("#1");
});
it("filters out invisible custom section items", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).custom = {
"custom-1": {
name: "Section",
columns: 1,
separateLinks: false,
visible: true,
id: "custom-1",
items: [
{ id: "ci1", visible: true, name: "Visible" },
{ id: "ci2", visible: false, name: "Hidden" },
],
},
};
const result = importer.parse(JSON.stringify(v4));
expect(result.customSections[0].items).toHaveLength(1);
});
it("handles empty custom sections object", () => {
const v4 = makeMinimalV4Resume();
(v4.sections as any).custom = {};
const result = importer.parse(JSON.stringify(v4));
expect(result.customSections).toHaveLength(0);
});
it("handles missing custom sections", () => {
const v4 = makeMinimalV4Resume();
delete (v4.sections as any).custom;
const result = importer.parse(JSON.stringify(v4));
expect(result.customSections).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
describe("edge cases", () => {
it("sanitizes invalid email addresses", () => {
const v4 = makeMinimalV4Resume();
v4.basics.email = "not-an-email";
const result = importer.parse(JSON.stringify(v4));
expect(result.basics.email).toBe("");
});
it("preserves valid email addresses", () => {
const v4 = makeMinimalV4Resume();
v4.basics.email = "valid@example.com";
const result = importer.parse(JSON.stringify(v4));
expect(result.basics.email).toBe("valid@example.com");
});
it("handles missing optional fields with defaults", () => {
const v4 = makeMinimalV4Resume();
v4.basics.phone = "";
v4.basics.location = "";
v4.basics.url = { label: "", href: "" };
const result = importer.parse(JSON.stringify(v4));
expect(result.basics.phone).toBe("");
expect(result.basics.location).toBe("");
expect(result.basics.website.url).toBe("");
});
it("handles missing picture data", () => {
const v4 = makeMinimalV4Resume();
(v4.basics as any).picture = undefined;
const result = importer.parse(JSON.stringify(v4));
expect(result.picture.url).toBe("");
expect(result.picture.size).toBe(80);
});
it("clamps picture size to valid range", () => {
const v4 = makeMinimalV4Resume();
v4.basics.picture.size = 1000;
const result = importer.parse(JSON.stringify(v4));
expect(result.picture.size).toBe(512);
});
it("clamps picture size minimum", () => {
const v4 = makeMinimalV4Resume();
v4.basics.picture.size = 5;
const result = importer.parse(JSON.stringify(v4));
expect(result.picture.size).toBe(32);
});
it("clamps aspect ratio to valid range", () => {
const v4 = makeMinimalV4Resume();
v4.basics.picture.aspectRatio = 10;
const result = importer.parse(JSON.stringify(v4));
expect(result.picture.aspectRatio).toBe(2.5);
});
it("converts font variants correctly", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.typography.font.variants = ["regular", "bold", "700"];
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.typography.body.fontWeights).toContain("400");
expect(result.metadata.typography.body.fontWeights).toContain("700");
});
it("heading font weights are >= 600", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.typography.font.variants = ["regular", "300"];
const result = importer.parse(JSON.stringify(v4));
// All heading weights should be >= 600; defaults to ["600"] if none qualify
for (const weight of result.metadata.typography.heading.fontWeights) {
expect(Number.parseInt(weight, 10)).toBeGreaterThanOrEqual(600);
}
});
it("handles custom fields in basics", () => {
const v4 = makeMinimalV4Resume();
(v4.basics.customFields as any) = [{ id: "cf1", icon: "star", text: "Custom field value" }];
const result = importer.parse(JSON.stringify(v4));
expect(result.basics.customFields).toHaveLength(1);
expect(result.basics.customFields[0].text).toBe("Custom field value");
expect(result.basics.customFields[0].icon).toBe("star");
});
it("throws on invalid JSON", () => {
expect(() => importer.parse("not json")).toThrow();
});
it("produces a result that validates against resumeDataSchema", () => {
const v4 = makeMinimalV4Resume();
// Add items to every section
(v4.sections as any).experience.items = [{ id: "e1", visible: true, company: "Co", position: "Dev" }];
(v4.sections as any).education.items = [{ id: "ed1", visible: true, institution: "Uni", studyType: "BSc" }];
(v4.sections as any).skills.items = [{ id: "s1", visible: true, name: "JS", level: 3 }];
(v4.sections as any).languages.items = [{ id: "l1", visible: true, name: "English", level: 5 }];
const result = importer.parse(JSON.stringify(v4));
const validation = resumeDataSchema.safeParse(result);
expect(validation.success).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Font weight conversion
// ---------------------------------------------------------------------------
describe("font weight conversion", () => {
it("maps 'bold' to 700", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.typography.font.variants = ["bold"];
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.typography.body.fontWeights).toContain("700");
});
it("maps 'italic' to 400", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.typography.font.variants = ["italic"];
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.typography.body.fontWeights).toContain("400");
});
it("defaults to 400 for empty variants", () => {
const v4 = makeMinimalV4Resume();
v4.metadata.typography.font.variants = [];
const result = importer.parse(JSON.stringify(v4));
expect(result.metadata.typography.body.fontWeights).toEqual(["400"]);
});
});
});
+93
View File
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it } from "vite-plus/test";
import { useJobsStore } from "./store";
afterEach(() => {
useJobsStore.getState().reset();
});
// ---------------------------------------------------------------------------
// Initial state
// ---------------------------------------------------------------------------
describe("Jobs Store — initial state", () => {
it("starts with default values", () => {
const state = useJobsStore.getState();
expect(state.rapidApiKey).toBe("");
expect(state.testStatus).toBe("unverified");
expect(state.rapidApiQuota).toBeNull();
});
});
// ---------------------------------------------------------------------------
// set()
// ---------------------------------------------------------------------------
describe("Jobs Store — set()", () => {
it("updates rapidApiKey", () => {
useJobsStore.getState().set((draft) => {
draft.rapidApiKey = "new-api-key";
});
expect(useJobsStore.getState().rapidApiKey).toBe("new-api-key");
});
it("resets testStatus to unverified when rapidApiKey changes", () => {
useJobsStore.getState().set((draft) => {
draft.testStatus = "success";
});
useJobsStore.getState().set((draft) => {
draft.rapidApiKey = "different-key";
});
expect(useJobsStore.getState().testStatus).toBe("unverified");
});
it("does NOT reset testStatus when rapidApiKey stays the same", () => {
useJobsStore.getState().set((draft) => {
draft.rapidApiKey = "same-key";
});
useJobsStore.getState().set((draft) => {
draft.testStatus = "success";
});
// Change something else, keep the same key
useJobsStore.getState().set((draft) => {
draft.rapidApiQuota = { used: 10, limit: 100, remaining: 90 };
});
expect(useJobsStore.getState().testStatus).toBe("success");
});
it("updates rapidApiQuota", () => {
const quota = { used: 50, limit: 100, remaining: 50 };
useJobsStore.getState().set((draft) => {
draft.rapidApiQuota = quota;
});
expect(useJobsStore.getState().rapidApiQuota).toEqual(quota);
});
it("updates testStatus directly", () => {
useJobsStore.getState().set((draft) => {
draft.testStatus = "failure";
});
expect(useJobsStore.getState().testStatus).toBe("failure");
});
});
// ---------------------------------------------------------------------------
// reset()
// ---------------------------------------------------------------------------
describe("Jobs Store — reset()", () => {
it("restores all state to initial values", () => {
useJobsStore.getState().set((draft) => {
draft.rapidApiKey = "some-key";
draft.testStatus = "success";
draft.rapidApiQuota = { used: 10, limit: 100, remaining: 90 };
});
useJobsStore.getState().reset();
const state = useJobsStore.getState();
expect(state.rapidApiKey).toBe("");
expect(state.testStatus).toBe("unverified");
expect(state.rapidApiQuota).toBeNull();
});
});
+6 -3
View File
@@ -26,7 +26,8 @@ async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
const [userResult] = await db.select().from(user).where(eq(user.id, payload.sub)).limit(1);
return userResult ?? null;
} catch {
} catch (error) {
console.warn("Bearer token verification failed:", error);
return null;
}
}
@@ -37,7 +38,8 @@ async function getUserFromHeaders(headers: Headers): Promise<User | null> {
if (!result || !result.user) return null;
return result.user;
} catch {
} catch (error) {
console.warn("Session verification failed:", error);
return null;
}
}
@@ -51,7 +53,8 @@ async function getUserFromApiKey(apiKey: string): Promise<User | null> {
if (!userResult) return null;
return userResult;
} catch {
} catch (error) {
console.warn("API key verification failed:", error);
return null;
}
}
@@ -0,0 +1,106 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vite-plus/test";
// ---------------------------------------------------------------------------
// We test the pure crypto helpers directly (signResumeAccessToken, safeEquals)
// since they don't depend on cookies or env.
// The actual module uses private functions, so we reimplement and test the algorithm.
// ---------------------------------------------------------------------------
function signResumeAccessToken(resumeId: string, passwordHash: string): string {
return createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
}
function safeEquals(value: string, expected: string): boolean {
const { timingSafeEqual } = require("node:crypto");
const valueBuffer = Buffer.from(value);
const expectedBuffer = Buffer.from(expected);
if (valueBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(valueBuffer, expectedBuffer);
}
function getResumeAccessCookieName(resumeId: string): string {
return `resume_access_${resumeId}`;
}
// ---------------------------------------------------------------------------
// signResumeAccessToken
// ---------------------------------------------------------------------------
describe("signResumeAccessToken", () => {
it("produces a hex SHA256 hash", () => {
const token = signResumeAccessToken("resume-123", "hashed-password");
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("produces consistent results for same inputs", () => {
const t1 = signResumeAccessToken("resume-1", "pw-hash");
const t2 = signResumeAccessToken("resume-1", "pw-hash");
expect(t1).toBe(t2);
});
it("produces different tokens for different resume IDs", () => {
const t1 = signResumeAccessToken("resume-1", "pw-hash");
const t2 = signResumeAccessToken("resume-2", "pw-hash");
expect(t1).not.toBe(t2);
});
it("produces different tokens for different password hashes", () => {
const t1 = signResumeAccessToken("resume-1", "hash-a");
const t2 = signResumeAccessToken("resume-1", "hash-b");
expect(t1).not.toBe(t2);
});
it("handles empty inputs", () => {
const token = signResumeAccessToken("", "");
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
});
// ---------------------------------------------------------------------------
// safeEquals
// ---------------------------------------------------------------------------
describe("safeEquals", () => {
it("returns true for equal strings", () => {
expect(safeEquals("abc123", "abc123")).toBe(true);
});
it("returns false for different strings of same length", () => {
expect(safeEquals("abc123", "abc456")).toBe(false);
});
it("returns false for strings of different lengths", () => {
expect(safeEquals("short", "much-longer-string")).toBe(false);
});
it("returns true for empty strings", () => {
expect(safeEquals("", "")).toBe(true);
});
it("returns false for empty vs non-empty", () => {
expect(safeEquals("", "notempty")).toBe(false);
});
it("handles long hex strings (like SHA256 hashes)", () => {
const hash = signResumeAccessToken("id", "pw");
expect(safeEquals(hash, hash)).toBe(true);
expect(safeEquals(hash, hash.replace(/.$/, "0"))).toBe(false);
});
});
// ---------------------------------------------------------------------------
// getResumeAccessCookieName
// ---------------------------------------------------------------------------
describe("getResumeAccessCookieName", () => {
it("prefixes resume ID with resume_access_", () => {
expect(getResumeAccessCookieName("abc-123")).toBe("resume_access_abc-123");
});
it("handles various ID formats", () => {
expect(getResumeAccessCookieName("01234567-89ab-cdef-0123-456789abcdef")).toBe(
"resume_access_01234567-89ab-cdef-0123-456789abcdef",
);
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ export const authRouter = {
operationId: "listAuthProviders",
summary: "List authentication providers",
description:
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, and custom OAuth. No authentication required.",
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, LinkedIn, and custom OAuth. No authentication required.",
successDescription: "A map of enabled authentication provider identifiers to their display names.",
})
.handler((): ProviderList => {
+2 -100
View File
@@ -33,108 +33,10 @@ import {
} from "@/integrations/ai/tools/patch-resume";
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
import { type TailorOutput, tailorOutputSchema } from "@/schema/tailor";
import { buildAiExtractionTemplate } from "@/utils/ai-template";
import { isObject } from "@/utils/sanitize";
const aiExtractionTemplate = {
...defaultResumeData,
basics: {
...defaultResumeData.basics,
customFields: [{ id: "", icon: "", text: "", link: "" }],
},
sections: {
...defaultResumeData.sections,
profiles: {
...defaultResumeData.sections.profiles,
items: [{ id: "", hidden: false, icon: "", network: "", username: "", website: { url: "", label: "" } }],
},
experience: {
...defaultResumeData.sections.experience,
items: [
{
id: "",
hidden: false,
company: "",
position: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
],
},
education: {
...defaultResumeData.sections.education,
items: [
{
id: "",
hidden: false,
school: "",
degree: "",
area: "",
grade: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
],
},
projects: {
...defaultResumeData.sections.projects,
items: [{ id: "", hidden: false, name: "", period: "", website: { url: "", label: "" }, description: "" }],
},
skills: {
...defaultResumeData.sections.skills,
items: [{ id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] }],
},
languages: {
...defaultResumeData.sections.languages,
items: [{ id: "", hidden: false, language: "", fluency: "", level: 0 }],
},
interests: {
...defaultResumeData.sections.interests,
items: [{ id: "", hidden: false, icon: "", name: "", keywords: [] }],
},
awards: {
...defaultResumeData.sections.awards,
items: [
{ id: "", hidden: false, title: "", awarder: "", date: "", website: { url: "", label: "" }, description: "" },
],
},
certifications: {
...defaultResumeData.sections.certifications,
items: [
{ id: "", hidden: false, title: "", issuer: "", date: "", website: { url: "", label: "" }, description: "" },
],
},
publications: {
...defaultResumeData.sections.publications,
items: [
{ id: "", hidden: false, title: "", publisher: "", date: "", website: { url: "", label: "" }, description: "" },
],
},
volunteer: {
...defaultResumeData.sections.volunteer,
items: [
{
id: "",
hidden: false,
organization: "",
location: "",
period: "",
website: { url: "", label: "" },
description: "",
},
],
},
references: {
...defaultResumeData.sections.references,
items: [
{ id: "", hidden: false, name: "", position: "", website: { url: "", label: "" }, phone: "", description: "" },
],
},
},
};
const aiExtractionTemplate = buildAiExtractionTemplate();
/**
* Merges two objects recursively, filling in missing properties in the target object
+1
View File
@@ -17,6 +17,7 @@ const providers = {
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) providers.google = "Google";
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) providers.github = "GitHub";
if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) providers.linkedin = "LinkedIn";
if (env.OAUTH_CLIENT_ID && env.OAUTH_CLIENT_SECRET) providers.custom = env.OAUTH_PROVIDER_NAME ?? "Custom OAuth";
return providers;
+327 -287
View File
@@ -3,7 +3,7 @@ import type { InferSelectModel } from "drizzle-orm";
import { ORPCError } from "@orpc/server";
import dns from "node:dns/promises";
import { isIP } from "node:net";
import puppeteer, { type Browser, type ConnectOptions, type Page } from "puppeteer-core";
import puppeteer, { type Browser, type BrowserContext, type ConnectOptions, type Page } from "puppeteer-core";
import type { schema } from "@/integrations/drizzle";
@@ -16,6 +16,10 @@ import { getStorageService, uploadFile } from "./storage";
const SCREENSHOT_TTL = 1000 * 60 * 60 * 6; // 6 hours
// Deduplicate concurrent requests for the same resume
const activePrintJobs = new Map<string, Promise<string>>();
const activeScreenshotJobs = new Map<string, Promise<string>>();
// Singleton browser instance for connection reuse
let browserInstance: Browser | null = null;
@@ -59,15 +63,312 @@ async function closeBrowser(): Promise<void> {
}
// Close browser on process termination
process.on("SIGINT", async () => {
process.on("exit", async () => {
await closeBrowser();
process.exit(0);
});
process.on("SIGTERM", async () => {
await closeBrowser();
process.exit(0);
});
/**
* Generates a PDF from a resume and uploads it to storage.
*
* The process:
* 1. Clean up any existing PDF for this resume
* 2. Navigate to the printer route which renders the resume
* 3. Calculate PDF margins (some templates require margins to be applied via PDF)
* 4. Adjust CSS variables so content fits within printable area (accounting for margins)
* 5. Add page break CSS to ensure each visual resume page becomes a PDF page
* 6. Generate the PDF with proper dimensions and margins
* 7. Upload to storage and return the URL
*/
async function doPrintResumeAsPDF(
input: Pick<InferSelectModel<typeof schema.resume>, "id" | "data" | "userId">,
): Promise<string> {
const { id, data, userId } = input;
// Step 1: Delete any existing PDF for this resume to ensure fresh generation
const storageService = getStorageService();
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
await storageService.delete(pdfPrefix);
// Step 2: Prepare the URL and authentication for the printer route
// The printer route renders the resume in a format optimized for PDF generation
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const format = data.metadata.page.format;
const locale = data.metadata.page.locale;
const template = data.metadata.template;
// Generate a secure token to authenticate the printer request
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
// Step 3: Calculate print paddings for templates that disable CSS padding in print mode.
// We render these margins inside the page (not via Puppeteer's PDF margins), so the margin
// area matches the resume background color instead of staying white.
let pagePaddingX = 0;
let pagePaddingY = 0;
if (printMarginTemplates.includes(template)) {
pagePaddingX = data.metadata.page.marginX;
pagePaddingY = data.metadata.page.marginY;
}
let context: BrowserContext | null = null;
let page: Page | null = null;
try {
// Step 4: Connect to the browser and navigate to the printer route
// Use an isolated browser context so concurrent requests with different locales don't interfere
const browser = await getBrowser();
context = await browser.createBrowserContext();
await context.setCookie({ name: "locale", value: locale, domain });
page = await context.newPage();
// Wait for the page to fully load (network idle + custom loaded attribute)
await page.emulateMediaType("print");
await page.setViewport(pageDimensionsAsPixels[format]);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
// Step 5a: Prepare the DOM for PDF rendering (background colors, reset margins, print padding)
await page.evaluate(
(pagePaddingX: number, pagePaddingY: number, backgroundColor: string) => {
const root = document.documentElement;
const body = document.body;
const pageElements = document.querySelectorAll("[data-page-index]");
const pageContentElements = document.querySelectorAll(".page-content");
// Ensure PDF margins inherit the resume background color instead of defaulting to white.
root.style.backgroundColor = backgroundColor;
body.style.backgroundColor = backgroundColor;
root.style.margin = "0";
body.style.margin = "0";
root.style.padding = "0";
body.style.padding = "0";
for (const el of pageElements) {
const pageWrapper = el as HTMLElement;
const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null;
pageWrapper.style.backgroundColor = backgroundColor;
pageWrapper.style.breakInside = "auto";
if (pageSurface) pageSurface.style.backgroundColor = backgroundColor;
}
// Apply print-only margins as padding inside each page's content surface.
if (pagePaddingX > 0 || pagePaddingY > 0) {
for (const el of pageContentElements) {
const pageContent = el as HTMLElement;
pageContent.style.boxSizing = "border-box";
// Ensure padding is repeated on every printed fragment when content
// flows across physical PDF pages (not just the first fragment).
pageContent.style.boxDecorationBreak = "clone";
pageContent.style.setProperty("-webkit-box-decoration-break", "clone");
if (pagePaddingX > 0) {
pageContent.style.paddingLeft = `${pagePaddingX}pt`;
pageContent.style.paddingRight = `${pagePaddingX}pt`;
}
if (pagePaddingY > 0) {
pageContent.style.paddingTop = `${pagePaddingY}pt`;
pageContent.style.paddingBottom = `${pagePaddingY}pt`;
}
}
}
},
pagePaddingX,
pagePaddingY,
data.metadata.design.colors.background,
);
// Step 5b: Format-specific layout adjustments
const isFreeForm = format === "free-form";
let contentHeight: number | null = null;
if (isFreeForm) {
// Free-form: measure actual content height after adding inter-page margins
contentHeight = await page.evaluate(
(pagePaddingY: number, minPageHeight: number) => {
const pageElements = document.querySelectorAll("[data-page-index]");
const numberOfPages = pageElements.length;
// Add margin between pages (except the last one)
for (let i = 0; i < numberOfPages - 1; i++) {
const pageEl = pageElements[i] as HTMLElement;
if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`;
}
// Measure the total height (margins are now part of the DOM)
let totalHeight = 0;
for (const el of pageElements) {
const pageEl = el as HTMLElement;
const style = getComputedStyle(pageEl);
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
totalHeight += pageEl.offsetHeight + marginBottom;
}
return Math.max(totalHeight, minPageHeight);
},
pagePaddingY,
pageDimensionsAsPixels[format].height,
);
} else {
// A4/Letter: set fixed page height and add page breaks between pages
await page.evaluate((pageHeight: number) => {
const root = document.documentElement;
const pageElements = document.querySelectorAll("[data-page-index]");
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
const newHeight = `${pageHeight}px`;
if (container) container.style.setProperty("--page-height", newHeight);
root.style.setProperty("--page-height", newHeight);
for (const el of pageElements) {
const element = el as HTMLElement;
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
// Force a page break before each page except the first
if (index > 0) {
element.style.breakBefore = "page";
element.style.pageBreakBefore = "always";
}
// Allow content within a page to break naturally if it overflows
element.style.breakInside = "auto";
}
}, pageDimensionsAsPixels[format].height);
}
// Step 6: Generate the PDF with the specified dimensions and margins
// For free-form: use measured content height (with minimum constraint)
// For A4/Letter: use fixed dimensions from pageDimensionsAsPixels
const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height;
const pdfBuffer = await page.pdf({
width: `${pageDimensionsAsPixels[format].width}px`,
height: `${pdfHeight}px`,
tagged: true, // Adds accessibility tags to the PDF
waitForFonts: true, // Ensures all fonts are loaded before rendering
printBackground: true, // Includes background colors and images
margin: {
bottom: 0,
top: 0,
right: 0,
left: 0,
},
});
// Step 7: Upload the generated PDF to storage
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(pdfBuffer),
contentType: "application/pdf",
type: "pdf",
});
return result.url;
} catch (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to generate PDF", cause: error });
} finally {
if (page) await page.close().catch(() => null);
if (context) await context.close().catch(() => null);
}
}
/**
* Captures a screenshot of the first page of a resume as WebP.
*
* Uses a timestamp-based cache (6-hour TTL) to avoid regenerating screenshots
* for resumes that haven't changed. Old screenshots are cleaned up on regeneration.
*/
async function doGetResumeScreenshot(
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data" | "updatedAt">,
): Promise<string> {
const { id, userId, data, updatedAt } = input;
const storageService = getStorageService();
const screenshotPrefix = `uploads/${userId}/screenshots/${id}`;
const existingScreenshots = await storageService.list(screenshotPrefix);
const now = Date.now();
const resumeUpdatedAt = updatedAt.getTime();
if (existingScreenshots.length > 0) {
const sortedFiles = existingScreenshots
.map((path) => {
const filename = path.split("/").pop();
const match = filename?.match(/^(\d+)\.webp$/);
return match ? { path, timestamp: Number(match[1]) } : null;
})
.filter((item): item is { path: string; timestamp: number } => item !== null)
.sort((a, b) => b.timestamp - a.timestamp);
if (sortedFiles.length > 0) {
const latest = sortedFiles[0];
const age = now - latest.timestamp;
// Return existing screenshot if it's still fresh (within TTL)
if (age < SCREENSHOT_TTL) return new URL(latest.path, env.APP_URL).toString();
// Screenshot is stale (past TTL), but only regenerate if the resume
// was updated after the screenshot was taken. If the resume hasn't
// changed, keep using the existing screenshot to avoid unnecessary work.
if (resumeUpdatedAt <= latest.timestamp) {
return new URL(latest.path, env.APP_URL).toString();
}
// Resume was updated after the screenshot - delete old ones and regenerate
await Promise.all(sortedFiles.map((file) => storageService.delete(file.path)));
}
}
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const locale = data.metadata.page.locale;
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
let context: BrowserContext | null = null;
let page: Page | null = null;
try {
const browser = await getBrowser();
context = await browser.createBrowserContext();
await context.setCookie({ name: "locale", value: locale, domain });
page = await context.newPage();
await page.setViewport(pageDimensionsAsPixels.a4);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(screenshotBuffer),
contentType: "image/webp",
type: "screenshot",
});
return result.url;
} catch (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to capture screenshot", cause: error });
} finally {
if (page) await page.close().catch(() => null);
if (context) await context.close().catch(() => null);
}
}
export const printerService = {
healthcheck: async (): Promise<object> => {
@@ -83,300 +384,39 @@ export const printerService = {
return data;
},
/**
* Generates a PDF from a resume and uploads it to storage.
*
* The process:
* 1. Clean up any existing PDF for this resume
* 2. Navigate to the printer route which renders the resume
* 3. Calculate PDF margins (some templates require margins to be applied via PDF)
* 4. Adjust CSS variables so content fits within printable area (accounting for margins)
* 5. Add page break CSS to ensure each visual resume page becomes a PDF page
* 6. Generate the PDF with proper dimensions and margins
* 7. Upload to storage and return the URL
*/
/** Generates a PDF, deduplicating concurrent requests for the same resume. */
printResumeAsPDF: async (
input: Pick<InferSelectModel<typeof schema.resume>, "id" | "data" | "userId">,
): Promise<string> => {
const { id, data, userId } = input;
const { id } = input;
// Step 1: Delete any existing PDF for this resume to ensure fresh generation
const storageService = getStorageService();
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
await storageService.delete(pdfPrefix);
// Deduplicate concurrent requests for the same resume
const existing = activePrintJobs.get(id);
if (existing) return existing;
// Step 2: Prepare the URL and authentication for the printer route
// The printer route renders the resume in a format optimized for PDF generation
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const job = doPrintResumeAsPDF(input).finally(() => {
activePrintJobs.delete(id);
});
const format = data.metadata.page.format;
const locale = data.metadata.page.locale;
const template = data.metadata.template;
// Generate a secure token to authenticate the printer request
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
// Step 3: Calculate print paddings for templates that disable CSS padding in print mode.
// We render these margins inside the page (not via Puppeteer's PDF margins), so the margin
// area matches the resume background color instead of staying white.
let pagePaddingX = 0;
let pagePaddingY = 0;
if (printMarginTemplates.includes(template)) {
pagePaddingX = data.metadata.page.marginX;
pagePaddingY = data.metadata.page.marginY;
}
let browser: Browser | null = null;
let page: Page | null = null;
try {
// Step 4: Connect to the browser and navigate to the printer route
browser = await getBrowser();
// Set locale cookie so the resume renders in the correct language
await browser.setCookie({ name: "locale", value: locale, domain });
page = await browser.newPage();
// Wait for the page to fully load (network idle + custom loaded attribute)
await page.emulateMediaType("print");
await page.setViewport(pageDimensionsAsPixels[format]);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
// Step 5: Adjust the DOM for proper PDF pagination
// This runs in the browser context to modify CSS before PDF generation
// For free-form: measure actual content height, don't add page breaks
// For A4/Letter: adjust page height for margins, add page breaks
const isFreeForm = format === "free-form";
const contentHeight = await page.evaluate(
(
pagePaddingX: number,
pagePaddingY: number,
isFreeForm: boolean,
minPageHeight: number,
backgroundColor: string,
) => {
const root = document.documentElement;
const body = document.body;
const pageElements = document.querySelectorAll("[data-page-index]");
const pageContentElements = document.querySelectorAll(".page-content");
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
// Ensure PDF margins inherit the resume background color instead of defaulting to white.
root.style.backgroundColor = backgroundColor;
body.style.backgroundColor = backgroundColor;
root.style.margin = "0";
body.style.margin = "0";
root.style.padding = "0";
body.style.padding = "0";
for (const el of pageElements) {
const pageWrapper = el as HTMLElement;
const pageSurface = pageWrapper.querySelector(".page") as HTMLElement | null;
pageWrapper.style.backgroundColor = backgroundColor;
pageWrapper.style.breakInside = "auto";
if (pageSurface) pageSurface.style.backgroundColor = backgroundColor;
}
// Apply print-only margins as padding inside each page's content surface.
if (pagePaddingX > 0 || pagePaddingY > 0) {
for (const el of pageContentElements) {
const pageContent = el as HTMLElement;
pageContent.style.boxSizing = "border-box";
// Ensure padding is repeated on every printed fragment when content
// flows across physical PDF pages (not just the first fragment).
pageContent.style.boxDecorationBreak = "clone";
pageContent.style.setProperty("-webkit-box-decoration-break", "clone");
if (pagePaddingX > 0) {
pageContent.style.paddingLeft = `${pagePaddingX}pt`;
pageContent.style.paddingRight = `${pagePaddingX}pt`;
}
if (pagePaddingY > 0) {
pageContent.style.paddingTop = `${pagePaddingY}pt`;
pageContent.style.paddingBottom = `${pagePaddingY}pt`;
}
}
}
if (isFreeForm) {
const numberOfPages = pageElements.length;
// Add margin between pages (except the last one)
for (let i = 0; i < numberOfPages - 1; i++) {
const pageEl = pageElements[i] as HTMLElement;
if (pagePaddingY > 0) pageEl.style.marginBottom = `${pagePaddingY}pt`;
}
// Now measure the total height (margins are now part of the DOM)
let totalHeight = 0;
for (const el of pageElements) {
const pageEl = el as HTMLElement;
// offsetHeight includes padding and border, but not margin
const style = getComputedStyle(pageEl);
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
totalHeight += pageEl.offsetHeight + marginBottom;
}
return Math.max(totalHeight, minPageHeight);
}
// For A4/Letter
const heightValue = minPageHeight;
// Keep page height fixed and let in-page padding (if any) define content bounds.
const newHeight = `${heightValue}px`;
if (container) container.style.setProperty("--page-height", newHeight);
root.style.setProperty("--page-height", newHeight);
// Add page break CSS to each resume page element (identified by data-page-index attribute)
// This ensures each visual resume page starts a new PDF page
for (const el of pageElements) {
const element = el as HTMLElement;
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
// Force a page break before each page except the first
if (index > 0) {
element.style.breakBefore = "page";
element.style.pageBreakBefore = "always";
}
// Allow content within a page to break naturally if it overflows
// (e.g., if a single page has more content than fits on one PDF page)
element.style.breakInside = "auto";
}
return null; // Fixed height from pageDimensionsAsPixels for A4/Letter
},
pagePaddingX,
pagePaddingY,
isFreeForm,
pageDimensionsAsPixels[format].height,
data.metadata.design.colors.background,
);
// Step 6: Generate the PDF with the specified dimensions and margins
// For free-form: use measured content height (with minimum constraint)
// For A4/Letter: use fixed dimensions from pageDimensionsAsPixels
const pdfHeight = isFreeForm && contentHeight ? contentHeight : pageDimensionsAsPixels[format].height;
const pdfBuffer = await page.pdf({
width: `${pageDimensionsAsPixels[format].width}px`,
height: `${pdfHeight}px`,
tagged: true, // Adds accessibility tags to the PDF
waitForFonts: true, // Ensures all fonts are loaded before rendering
printBackground: true, // Includes background colors and images
margin: {
bottom: 0,
top: 0,
right: 0,
left: 0,
},
});
// Step 7: Upload the generated PDF to storage
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(pdfBuffer),
contentType: "application/pdf",
type: "pdf",
});
return result.url;
} catch (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
} finally {
if (page) await page.close().catch(() => null);
}
activePrintJobs.set(id, job);
return job;
},
/** Captures a resume screenshot, deduplicating concurrent requests for the same resume. */
getResumeScreenshot: async (
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data" | "updatedAt">,
): Promise<string> => {
const { id, userId, data, updatedAt } = input;
const { id } = input;
const storageService = getStorageService();
const screenshotPrefix = `uploads/${userId}/screenshots/${id}`;
// Deduplicate concurrent requests for the same resume
const existing = activeScreenshotJobs.get(id);
if (existing) return existing;
const existingScreenshots = await storageService.list(screenshotPrefix);
const now = Date.now();
const resumeUpdatedAt = updatedAt.getTime();
const job = doGetResumeScreenshot(input).finally(() => {
activeScreenshotJobs.delete(id);
});
if (existingScreenshots.length > 0) {
const sortedFiles = existingScreenshots
.map((path) => {
const filename = path.split("/").pop();
const match = filename?.match(/^(\d+)\.webp$/);
return match ? { path, timestamp: Number(match[1]) } : null;
})
.filter((item): item is { path: string; timestamp: number } => item !== null)
.sort((a, b) => b.timestamp - a.timestamp);
if (sortedFiles.length > 0) {
const latest = sortedFiles[0];
const age = now - latest.timestamp;
// Return existing screenshot if it's still fresh (within TTL)
if (age < SCREENSHOT_TTL) return new URL(latest.path, env.APP_URL).toString();
// Screenshot is stale (past TTL), but only regenerate if the resume
// was updated after the screenshot was taken. If the resume hasn't
// changed, keep using the existing screenshot to avoid unnecessary work.
if (resumeUpdatedAt <= latest.timestamp) {
return new URL(latest.path, env.APP_URL).toString();
}
// Resume was updated after the screenshot - delete old ones and regenerate
await Promise.all(sortedFiles.map((file) => storageService.delete(file.path)));
}
}
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const locale = data.metadata.page.locale;
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
let browser: Browser | null = null;
let page: Page | null = null;
try {
browser = await getBrowser();
await browser.setCookie({ name: "locale", value: locale, domain });
page = await browser.newPage();
await page.setViewport(pageDimensionsAsPixels.a4);
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
const result = await uploadFile({
userId,
resumeId: id,
data: new Uint8Array(screenshotBuffer),
contentType: "image/webp",
type: "screenshot",
});
return result.url;
} catch (error) {
throw new ORPCError("INTERNAL_SERVER_ERROR", error as Error);
} finally {
if (page) await page.close().catch(() => null);
}
activeScreenshotJobs.set(id, job);
return job;
},
};
+19 -20
View File
@@ -269,7 +269,8 @@ export const resumeService = {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
throw error;
console.error("Failed to create resume:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to create resume" });
}
},
@@ -325,7 +326,8 @@ export const resumeService = {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
throw error;
console.error("Failed to update resume:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update resume" });
}
},
@@ -426,26 +428,23 @@ export const resumeService = {
},
delete: async (input: { id: string; userId: string }) => {
const [resume] = await db
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
await db.transaction(async (tx) => {
const [resume] = await tx
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
if (resume.isLocked) throw new ORPCError("RESUME_LOCKED");
if (!resume) throw new ORPCError("NOT_FOUND");
if (resume.isLocked) throw new ORPCError("RESUME_LOCKED");
await tx.delete(schema.resume).where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
});
// Clean up storage files after the DB transaction succeeds
const storageService = getStorageService();
const deleteResumePromise = db
.delete(schema.resume)
.where(
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
);
// Delete screenshots and PDFs using the new key format
const deleteScreenshotsPromise = storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`);
const deletePdfsPromise = storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`);
await Promise.allSettled([deleteResumePromise, deleteScreenshotsPromise, deletePdfsPromise]);
await Promise.allSettled([
storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`),
storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`),
]);
},
};
+3 -6
View File
@@ -70,14 +70,13 @@ export function FAQ() {
>
<motion.h2
className={cn(
"flex-1 text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl",
"flex-1 text-2xl font-semibold tracking-tight will-change-[transform,opacity] md:text-4xl xl:text-5xl",
"flex shrink-0 flex-wrap items-center gap-x-1.5 lg:flex-col lg:items-start",
)}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
style={{ willChange: "transform, opacity" }}
>
<Trans context="Every word needs to be wrapped in a tag">
<span>Frequently</span>
@@ -87,12 +86,11 @@ export function FAQ() {
</motion.h2>
<motion.div
className="max-w-2xl flex-2 lg:ml-auto 2xl:max-w-3xl"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: 0.08 }}
style={{ willChange: "transform, opacity" }}
className="max-w-2xl flex-2 will-change-[transform,opacity] lg:ml-auto 2xl:max-w-3xl"
>
<Accordion multiple>
{faqItems.map((item, index) => (
@@ -112,12 +110,11 @@ type FAQItemComponentProps = {
function FAQItemComponent({ item, index }: FAQItemComponentProps) {
return (
<motion.div
className="last:border-b"
className="will-change-[transform,opacity] last:border-b"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.24, delay: Math.min(0.16, index * 0.03) }}
style={{ willChange: "transform, opacity" }}
>
<AccordionItem value={item.question} className="group border-t">
<AccordionTrigger className="py-5">{item.question}</AccordionTrigger>
+2 -4
View File
@@ -136,7 +136,7 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
return (
<motion.div
className={cn(
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300",
"group relative flex min-h-48 flex-col gap-4 overflow-hidden border-b bg-background p-6 transition-[background-color] duration-300 will-change-[transform,opacity]",
"not-nth-[2n]:border-r xl:not-nth-[4n]:border-r",
"hover:bg-secondary/30",
)}
@@ -146,7 +146,6 @@ function FeatureCard({ icon: Icon, title, description }: FeatureCardProps) {
transition={{ duration: 0.35, ease: "easeOut" }}
whileHover={{ y: -3, scale: 1.01 }}
whileTap={{ scale: 0.995 }}
style={{ willChange: "transform, opacity" }}
>
{/* Hover gradient overlay */}
<div
@@ -177,12 +176,11 @@ export function Features() {
<section id="features">
{/* Header */}
<motion.div
className="space-y-4 p-4 md:p-8 xl:py-16"
className="space-y-4 p-4 will-change-[transform,opacity] md:p-8 xl:py-16"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
style={{ willChange: "transform, opacity" }}
>
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
<Trans>Features</Trans>
+2 -4
View File
@@ -50,12 +50,11 @@ export function Footer() {
return (
<motion.footer
id="footer"
className="p-4 pb-8 md:p-8 md:pb-12"
className="p-4 pb-8 will-change-[opacity] md:p-8 md:pb-12"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
style={{ willChange: "opacity" }}
>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
{/* Brand Column */}
@@ -143,8 +142,7 @@ function FooterLink({ url, label }: FooterLinkItem) {
initial={{ width: 0, opacity: 0 }}
animate={isHovered ? { width: "100%", opacity: 1 } : { width: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary"
style={{ willChange: "width, opacity" }}
className="pointer-events-none absolute inset-s-0 -bottom-0.5 h-px rounded-md bg-primary will-change-[width,opacity]"
/>
</a>
</li>
+6 -9
View File
@@ -18,10 +18,10 @@ export function Hero() {
<Spotlight />
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1.1, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<CometCard glareOpacity={0} className="3xl:max-w-7xl relative -mb-12 max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
<video
@@ -46,12 +46,12 @@ export function Hero() {
<div className="xs:px-0 relative z-10 flex max-w-2xl flex-col items-center gap-y-6 px-4 text-center">
{/* Badge */}
<motion.a
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.55 }}
whileHover={{ y: -2, scale: 1.01 }}
whileTap={{ scale: 0.985 }}
style={{ willChange: "transform, opacity" }}
target="_blank"
rel="noopener noreferrer"
href="https://docs.rxresu.me/getting-started"
@@ -64,10 +64,10 @@ export function Hero() {
{/* Headline */}
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.7 }}
style={{ willChange: "transform, opacity" }}
>
<Trans>
<p className="font-medium tracking-tight text-muted-foreground md:text-lg">Finally,</p>
@@ -79,11 +79,10 @@ export function Hero() {
{/* Description */}
<motion.p
className="max-w-xl text-base leading-relaxed text-muted-foreground md:text-lg"
className="max-w-xl text-base leading-relaxed text-muted-foreground will-change-[transform,opacity] md:text-lg"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.82 }}
style={{ willChange: "transform, opacity" }}
>
<Trans>
Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating,
@@ -93,11 +92,10 @@ export function Hero() {
{/* CTA Buttons */}
<motion.div
className="flex flex-col items-center gap-3 sm:flex-row sm:gap-4"
className="flex flex-col items-center gap-3 will-change-[transform,opacity] sm:flex-row sm:gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.95 }}
style={{ willChange: "transform, opacity" }}
>
<Button
size="lg"
@@ -144,10 +142,9 @@ export function Hero() {
transition={{ delay: 1.25, duration: 0.7 }}
>
<motion.div
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5"
className="flex h-8 w-5 items-start justify-center rounded-full border border-muted-foreground/30 p-1.5 will-change-transform"
animate={{ y: [0, 5, 0] }}
transition={{ duration: 1.5, repeat: Infinity, ease: "easeInOut" }}
style={{ willChange: "transform" }}
>
<motion.div className="h-1.5 w-1 rounded-full bg-muted-foreground/50" />
</motion.div>
+1 -2
View File
@@ -16,12 +16,11 @@ export function Prefooter() {
<TextMaskEffect aria-hidden="true" text="Reactive Resume" className="hidden md:block" />
<motion.div
className="mx-auto max-w-3xl space-y-8 px-6 text-center md:px-8 xl:px-0"
className="mx-auto max-w-3xl space-y-8 px-6 text-center will-change-[transform,opacity] md:px-8 xl:px-0"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
style={{ willChange: "transform, opacity" }}
>
<h2 className="text-2xl font-bold tracking-tight md:text-4xl">
<Trans>By the community, for the community.</Trans>
+2 -4
View File
@@ -13,12 +13,11 @@ type TemplateItemProps = {
function TemplateItem({ metadata }: TemplateItemProps) {
return (
<motion.div
className="group relative shrink-0"
className="group relative shrink-0 will-change-transform"
initial={{ scale: 1, zIndex: 10 }}
whileHover={{ scale: 1.06, zIndex: 20 }}
whileTap={{ scale: 0.99 }}
transition={{ type: "spring", stiffness: 320, damping: 26 }}
style={{ willChange: "transform" }}
>
<div className="relative aspect-page w-48 overflow-hidden rounded-md border bg-card shadow-lg transition-all duration-300 group-hover:shadow-2xl sm:w-56 md:w-64 lg:w-72">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
@@ -86,12 +85,11 @@ export function Templates() {
return (
<section id="templates" className="overflow-hidden border-t-0! p-4 md:p-8 xl:py-16">
<motion.div
className="space-y-4"
className="space-y-4 will-change-[transform,opacity]"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.35 }}
style={{ willChange: "transform, opacity" }}
>
<h2 className="text-2xl font-semibold tracking-tight md:text-4xl xl:text-5xl">
<Trans>Templates</Trans>
+1 -2
View File
@@ -35,12 +35,11 @@ type TestimonialCardProps = {
function TestimonialCard({ testimonial }: TestimonialCardProps) {
return (
<motion.div
className="group relative w-[320px] shrink-0 sm:w-[360px] md:w-[400px]"
className="group relative w-[320px] shrink-0 will-change-transform sm:w-[360px] md:w-[400px]"
initial={{ scale: 1 }}
whileHover={{ y: -3, scale: 1.02 }}
whileTap={{ scale: 0.995 }}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
style={{ willChange: "transform" }}
>
<div className="relative flex h-full flex-col rounded-md border bg-card p-5 shadow-sm transition-shadow duration-300 group-hover:shadow-xl">
<p className="flex-1 leading-relaxed text-muted-foreground">"{testimonial}"</p>
+79 -45
View File
@@ -1,18 +1,55 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { GithubLogoIcon, GoogleLogoIcon, VaultIcon } from "@phosphor-icons/react";
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, VaultIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router";
import { toast } from "sonner";
import type { RouterOutput } from "@/integrations/orpc/client";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { authClient } from "@/integrations/auth/client";
import { orpc } from "@/integrations/orpc/client";
import { cn } from "@/utils/style";
export function SocialAuth() {
const { data: providers = {}, isLoading } = useQuery(orpc.auth.providers.list.queryOptions());
return (
<>
<div className="flex items-center gap-x-2">
<hr className="flex-1" />
<span className="text-xs font-medium tracking-wide">
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
or continue with
</Trans>
</span>
<hr className="flex-1" />
</div>
{isLoading ? <SocialAuthSkeleton /> : <SocialAuthButtons providers={providers} />}
</>
);
}
function SocialAuthSkeleton() {
return (
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
);
}
type SocialAuthButtonsProps = {
providers: RouterOutput["auth"]["providers"]["list"];
};
function SocialAuthButtons({ providers }: SocialAuthButtonsProps) {
const router = useRouter();
const { data: authProviders = {} } = useQuery(orpc.auth.providers.list.queryOptions());
const handleSocialLogin = async (provider: string) => {
const toastId = toast.loading(t`Signing in...`);
@@ -49,51 +86,48 @@ export function SocialAuth() {
};
return (
<>
<div className="flex items-center gap-x-2">
<hr className="flex-1" />
<span className="text-xs font-medium tracking-wide">
<Trans context="Choose to authenticate with a social provider (Google, GitHub, etc.) instead of email and password">
or continue with
</Trans>
</span>
<hr className="flex-1" />
</div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
onClick={handleOAuthLogin}
className={cn("hidden", "custom" in providers && "inline-flex")}
>
<VaultIcon />
{providers.custom}
</Button>
<div>
<div className="grid grid-cols-2 gap-4">
<Button
variant="secondary"
onClick={handleOAuthLogin}
className={cn("hidden", "custom" in authProviders && "inline-flex")}
>
<VaultIcon />
{authProviders.custom}
</Button>
<Button
onClick={() => handleSocialLogin("google")}
className={cn(
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
"google" in providers && "inline-flex",
)}
>
<GoogleLogoIcon />
Google
</Button>
<Button
onClick={() => handleSocialLogin("google")}
className={cn(
"hidden flex-1 bg-[#4285F4] text-white hover:bg-[#4285F4]/80",
"google" in authProviders && "inline-flex",
)}
>
<GoogleLogoIcon />
Google
</Button>
<Button
onClick={() => handleSocialLogin("github")}
className={cn(
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
"github" in providers && "inline-flex",
)}
>
<GithubLogoIcon />
GitHub
</Button>
<Button
onClick={() => handleSocialLogin("github")}
className={cn(
"hidden flex-1 bg-[#2b3137] text-white hover:bg-[#2b3137]/80",
"github" in authProviders && "inline-flex",
)}
>
<GithubLogoIcon />
GitHub
</Button>
</div>
</div>
</>
<Button
onClick={() => handleSocialLogin("linkedin")}
className={cn(
"hidden flex-1 bg-[#0A66C2] text-white hover:bg-[#0A66C2]/80",
"linkedin" in providers && "inline-flex",
)}
>
<LinkedinLogoIcon />
LinkedIn
</Button>
</div>
);
}
@@ -111,8 +111,7 @@ export function BuilderDock() {
animate={{ opacity: 0.6, y: 0 }}
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="flex items-center rounded-l-full rounded-r-full bg-popover px-2 shadow-xl"
className="flex items-center rounded-l-full rounded-r-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
>
<DockIcon
disabled={!canUndo}
@@ -166,10 +165,10 @@ function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockI
<TooltipTrigger
render={
<motion.div
className="will-change-transform"
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
whileTap={disabled ? undefined : { scale: 0.97 }}
transition={{ duration: 0.15, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
<Icon className={cn("size-4", iconClassName)} />
@@ -245,8 +245,7 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="group relative flex h-18 border-b select-none"
className="group relative flex h-18 border-b will-change-[transform,opacity] select-none"
>
<div
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] group-hover:opacity-100 hover:bg-secondary/40"
@@ -192,8 +192,7 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ willChange: "transform" }}
className="absolute inset-0 flex size-8 items-center justify-center"
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
>
<div className="size-4 rounded-md bg-foreground" />
</motion.div>
+52 -2
View File
@@ -1,4 +1,4 @@
import type { usePanelRef } from "react-resizable-panels";
import type { Layout, usePanelRef } from "react-resizable-panels";
import { useCallback, useMemo } from "react";
import { useWindowSize } from "usehooks-ts";
@@ -8,12 +8,61 @@ import { useIsMobile } from "@/hooks/use-mobile";
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
export const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
export type BuilderLayout = {
left: number;
artboard: number;
right: number;
};
export const DEFAULT_BUILDER_LAYOUT: BuilderLayout = {
left: 22,
artboard: 56,
right: 22,
};
export const mapPanelLayoutToBuilderLayout = (layout: Layout): BuilderLayout => {
const left = layout.left;
const artboard = layout.artboard;
const right = layout.right;
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
return DEFAULT_BUILDER_LAYOUT;
return { left, artboard, right };
};
export const parseBuilderLayoutCookie = (value?: string | null): BuilderLayout => {
if (!value) return DEFAULT_BUILDER_LAYOUT;
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return DEFAULT_BUILDER_LAYOUT;
if (typeof parsed !== "object" || parsed === null) return DEFAULT_BUILDER_LAYOUT;
const left = (parsed as { left?: unknown }).left;
const artboard = (parsed as { artboard?: unknown }).artboard;
const right = (parsed as { right?: unknown }).right;
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
return DEFAULT_BUILDER_LAYOUT;
return { left, artboard, right };
} catch {
return DEFAULT_BUILDER_LAYOUT;
}
};
interface BuilderSidebarState {
layout: BuilderLayout;
leftSidebar: PanelImperativeHandle | null;
rightSidebar: PanelImperativeHandle | null;
}
interface BuilderSidebarActions {
setLayout: (layout: BuilderLayout) => void;
setLeftSidebar: (ref: PanelImperativeHandle | null) => void;
setRightSidebar: (ref: PanelImperativeHandle | null) => void;
}
@@ -21,9 +70,10 @@ interface BuilderSidebarActions {
type BuilderSidebar = BuilderSidebarState & BuilderSidebarActions;
export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
isDragging: false,
layout: DEFAULT_BUILDER_LAYOUT,
leftSidebar: null,
rightSidebar: null,
setLayout: (layout) => set({ layout }),
setLeftSidebar: (ref) => set({ leftSidebar: ref }),
setRightSidebar: (ref) => set({ rightSidebar: ref }),
}));
+41 -27
View File
@@ -1,13 +1,12 @@
import type React from "react";
import type { Layout } from "react-resizable-panels";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { useEffect } from "react";
import { type Layout, usePanelRef } from "react-resizable-panels";
import { useDebounceCallback } from "usehooks-ts";
import z from "zod";
import { useEffect, useRef } from "react";
import { usePanelRef } from "react-resizable-panels";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { useCSSVariables } from "@/components/resume/hooks/use-css-variables";
@@ -19,7 +18,15 @@ import { orpc } from "@/integrations/orpc/client";
import { BuilderHeader } from "./-components/header";
import { BuilderSidebarLeft } from "./-sidebar/left";
import { BuilderSidebarRight } from "./-sidebar/right";
import { useBuilderSidebar, useBuilderSidebarStore } from "./-store/sidebar";
import {
BUILDER_LAYOUT_COOKIE_NAME,
DEFAULT_BUILDER_LAYOUT,
mapPanelLayoutToBuilderLayout,
parseBuilderLayoutCookie,
useBuilderSidebar,
useBuilderSidebarStore,
type BuilderLayout,
} from "./-store/sidebar";
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
@@ -61,26 +68,36 @@ function RouteComponent() {
}
type BuilderLayoutProps = React.ComponentProps<"div"> & {
initialLayout: Layout;
initialLayout: BuilderLayout;
};
function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
const isMobile = useIsMobile();
const canPersistLayoutRef = useRef(false);
const leftSidebarRef = usePanelRef();
const rightSidebarRef = usePanelRef();
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
maxSidebarSize: state.maxSidebarSize,
collapsedSidebarSize: state.collapsedSidebarSize,
}));
const onLayoutChange = useDebounceCallback((layout: Layout) => {
void setBuilderLayoutServerFn({ data: layout });
}, 200);
useEffect(() => {
setLayout(initialLayout);
canPersistLayoutRef.current = true;
}, [initialLayout, setLayout]);
const onLayoutChanged = (layout: Layout) => {
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
if (!canPersistLayoutRef.current) return;
setLayout(nextLayout);
void setBuilderLayoutServerFn({ data: nextLayout });
};
useEffect(() => {
if (!leftSidebarRef || !rightSidebarRef) return;
@@ -89,22 +106,24 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
setRightSidebar(rightSidebarRef);
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
const leftSidebarSize = isMobile ? 0 : initialLayout.left;
const rightSidebarSize = isMobile ? 0 : initialLayout.right;
const artboardSize = isMobile ? 100 : initialLayout.artboard;
const sidebarMinSize = isMobile ? "0%" : `${collapsedSidebarSize * 2}px`;
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
const leftSidebarSize = isMobile ? "0%" : `${initialLayout.left}%`;
const rightSidebarSize = isMobile ? "0%" : `${initialLayout.right}%`;
const artboardSize = isMobile ? "100%" : `${initialLayout.artboard}%`;
return (
<div className="flex h-svh flex-col" {...props}>
<BuilderHeader />
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChange={onLayoutChange}>
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
<ResizablePanel
collapsible
id="left"
panelRef={leftSidebarRef}
maxSize={maxSidebarSize}
minSize={collapsedSidebarSize * 2}
collapsedSize={collapsedSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={leftSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
@@ -120,8 +139,8 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
id="right"
panelRef={rightSidebarRef}
maxSize={maxSidebarSize}
minSize={collapsedSidebarSize * 2}
collapsedSize={collapsedSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={rightSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
@@ -132,19 +151,14 @@ function BuilderLayout({ initialLayout, ...props }: BuilderLayoutProps) {
);
}
const defaultLayout = { left: 30, artboard: 40, right: 30 };
const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
const layoutSchema = z.record(z.string(), z.number()).catch(defaultLayout);
const setBuilderLayoutServerFn = createServerFn({ method: "POST" })
.inputValidator(layoutSchema)
.inputValidator((data): BuilderLayout => parseBuilderLayoutCookie(JSON.stringify(data)))
.handler(async ({ data }) => {
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data));
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data), { path: "/" });
});
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async () => {
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async (): Promise<BuilderLayout> => {
const layout = getCookie(BUILDER_LAYOUT_COOKIE_NAME);
if (!layout) return defaultLayout;
return layoutSchema.parse(JSON.parse(layout));
if (!layout) return DEFAULT_BUILDER_LAYOUT;
return parseBuilderLayoutCookie(layout);
});
+1 -1
View File
@@ -194,11 +194,11 @@ export function DashboardSidebar() {
{state === "expanded" && (
<motion.div
key="copyright"
className="will-change-[transform,opacity]"
initial={{ y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 12, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Copyright className="shrink-0 p-2 wrap-break-word whitespace-normal" />
</motion.div>
@@ -0,0 +1,83 @@
import { Trans } from "@lingui/react/macro";
import { BriefcaseIcon, BuildingsIcon, ClockIcon, GlobeIcon, MapPinIcon, MoneyIcon } from "@phosphor-icons/react";
import { motion } from "motion/react";
import type { JobResult } from "@/schema/jobs";
import { Badge } from "@/components/ui/badge";
import { formatPostedDate, formatSalary } from "./job-utils";
type Props = {
job: JobResult;
onClick: () => void;
};
export function JobCard({ job, onClick }: Props) {
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
const posted = formatPostedDate(job.job_posted_at_timestamp);
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
return (
<motion.button
type="button"
className="flex w-full cursor-pointer flex-col gap-y-3 rounded-md border bg-card p-4 text-start transition-colors hover:bg-accent/50"
onClick={onClick}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex items-start gap-x-3">
{job.employer_logo ? (
<img src={job.employer_logo} alt={job.employer_name} className="size-10 shrink-0 rounded-md object-contain" />
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
<BuildingsIcon className="size-5 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium">{job.job_title}</h3>
<p className="truncate text-sm text-muted-foreground">{job.employer_name}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{location && (
<Badge variant="secondary" className="gap-x-1">
<MapPinIcon className="size-3" />
{location}
</Badge>
)}
{job.job_is_remote && (
<Badge variant="secondary" className="gap-x-1">
<GlobeIcon className="size-3" />
<Trans>Remote</Trans>
</Badge>
)}
{job.job_employment_type && (
<Badge variant="secondary" className="gap-x-1">
<BriefcaseIcon className="size-3" />
{job.job_employment_type.replaceAll("_", " ")}
</Badge>
)}
{salary && (
<Badge variant="secondary" className="gap-x-1">
<MoneyIcon className="size-3" />
{salary}
</Badge>
)}
{posted && (
<Badge variant="outline" className="gap-x-1">
<ClockIcon className="size-3" />
{posted}
</Badge>
)}
</div>
</motion.button>
);
}
@@ -19,7 +19,7 @@ import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useAIStore } from "@/integrations/ai/store";
import { sanitizeHtml } from "@/utils/sanitize";
import { formatSalary, isValidExternalUrl } from "./job-utils";
import { TailorDialog } from "./tailor-dialog";
@@ -31,7 +31,6 @@ type Props = {
};
export function JobDetailSheet({ job, open, onOpenChange }: Props) {
const isAIEnabled = useAIStore((s) => s.enabled);
const [tailorOpen, setTailorOpen] = useState(false);
if (!job) return null;
@@ -119,12 +118,7 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
<Trans>Apply</Trans>
</Button>
<Button
variant="outline"
className="flex-1"
disabled={!isAIEnabled}
onClick={() => setTailorOpen(true)}
>
<Button variant="outline" className="flex-1" onClick={() => setTailorOpen(true)}>
<StarIcon />
<Trans>Tailor Resume</Trans>
</Button>
@@ -137,9 +131,10 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
<h4 className="font-medium">
<Trans>Description</Trans>
</h4>
<p className="text-sm leading-relaxed whitespace-pre-wrap text-muted-foreground">
{job.job_description}
</p>
<div
className="text-sm leading-relaxed text-muted-foreground [&_a]:text-primary [&_a]:underline [&_h1]:text-base [&_h1]:font-semibold [&_h1]:text-foreground [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:text-foreground [&_h3]:text-sm [&_h3]:font-medium [&_h3]:text-foreground [&_li]:ml-4 [&_ol]:list-decimal [&_p]:mb-2 [&_strong]:font-semibold [&_strong]:text-foreground [&_ul]:list-disc"
dangerouslySetInnerHTML={{ __html: sanitizeHtml(job.job_description) }}
/>
</div>
)}
@@ -197,7 +192,7 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
</>
)}
{job.apply_options.length > 0 && (
{job.apply_options.some((option) => isValidExternalUrl(option.apply_link)) && (
<>
<Separator />
<div className="flex flex-col gap-y-2">
@@ -205,23 +200,25 @@ export function JobDetailSheet({ job, open, onOpenChange }: Props) {
<Trans>Apply Via</Trans>
</h4>
<div className="flex flex-col gap-y-1.5">
{job.apply_options.map((option, i) => (
<a
key={i}
href={option.apply_link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
>
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
{option.publisher || t`Apply Link`}
{option.is_direct && (
<Badge variant="outline" className="text-[10px]">
<Trans>Direct</Trans>
</Badge>
)}
</a>
))}
{job.apply_options
.filter((option) => isValidExternalUrl(option.apply_link))
.map((option, i) => (
<a
key={i}
href={option.apply_link}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-x-2 text-sm text-primary hover:underline"
>
<ArrowSquareOutIcon className="size-3.5 shrink-0" />
{option.publisher || t`Apply Link`}
{option.is_direct && (
<Badge variant="outline" className="text-[10px]">
<Trans>Direct</Trans>
</Badge>
)}
</a>
))}
</div>
</div>
</>
@@ -2,7 +2,6 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ReadCvLogoIcon } from "@phosphor-icons/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
@@ -40,7 +39,6 @@ type DialogPhase =
| { step: "skill-sync"; newResumeId: string; newSkills: NewSkillInfo[]; sourceResumeId: string };
export function TailorDialog({ job, open, onOpenChange }: Props) {
const navigate = useNavigate();
const { data: resumes, isLoading } = useQuery(orpc.resume.list.queryOptions());
const [phase, setPhase] = useState<DialogPhase>({ step: "select" });
@@ -65,11 +63,7 @@ export function TailorDialog({ job, open, onOpenChange }: Props) {
};
const navigateToBuilder = (resumeId: string) => {
if (job.job_apply_link) {
window.open(job.job_apply_link, "_blank", "noopener,noreferrer");
}
handleOpenChange(false);
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
window.open(`/builder/${resumeId}`, "_blank", "noopener,noreferrer");
};
const handleSelectResume = async (resumeId: string, resumeName: string) => {
+17 -82
View File
@@ -2,14 +2,9 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
BriefcaseIcon,
BuildingsIcon,
CaretLeftIcon,
CaretRightIcon,
ClockIcon,
GlobeIcon,
MagnifyingGlassIcon,
MapPinIcon,
MoneyIcon,
WarningCircleIcon,
XIcon,
} from "@phosphor-icons/react";
@@ -17,19 +12,19 @@ import { createFileRoute, Link } from "@tanstack/react-router";
import { motion } from "motion/react";
import { useMemo } from "react";
import type { JobResult } from "@/schema/jobs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/utils/style";
import { DashboardHeader } from "../-components/header";
import { JobCard } from "./-components/job-card";
import { JobDetailSheet } from "./-components/job-detail";
import { formatPostedDate, formatSalary, getQuotaStatus } from "./-components/job-utils";
import { getQuotaStatus } from "./-components/job-utils";
import { hasActiveFilters, initialFilterState, SearchFilters } from "./-components/search-filters";
import { useJobSearch } from "./-components/use-job-search";
@@ -37,75 +32,6 @@ export const Route = createFileRoute("/dashboard/job-search/")({
component: RouteComponent,
});
function JobCard({ job, onClick }: { job: JobResult; onClick: () => void }) {
const salary = formatSalary(job.job_min_salary, job.job_max_salary, job.job_salary_currency, job.job_salary_period);
const posted = formatPostedDate(job.job_posted_at_timestamp);
const location = [job.job_city, job.job_state, job.job_country].filter(Boolean).join(", ");
return (
<motion.button
type="button"
className="flex w-full cursor-pointer flex-col gap-y-3 rounded-md border bg-card p-4 text-start transition-colors hover:bg-accent/50"
onClick={onClick}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex items-start gap-x-3">
{job.employer_logo ? (
<img src={job.employer_logo} alt={job.employer_name} className="size-10 shrink-0 rounded-md object-contain" />
) : (
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-muted">
<BuildingsIcon className="size-5 text-muted-foreground" />
</div>
)}
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium">{job.job_title}</h3>
<p className="truncate text-sm text-muted-foreground">{job.employer_name}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{location && (
<Badge variant="secondary" className="gap-x-1">
<MapPinIcon className="size-3" />
{location}
</Badge>
)}
{job.job_is_remote && (
<Badge variant="secondary" className="gap-x-1">
<GlobeIcon className="size-3" />
<Trans>Remote</Trans>
</Badge>
)}
{job.job_employment_type && (
<Badge variant="secondary" className="gap-x-1">
<BriefcaseIcon className="size-3" />
{job.job_employment_type.replaceAll("_", " ")}
</Badge>
)}
{salary && (
<Badge variant="secondary" className="gap-x-1">
<MoneyIcon className="size-3" />
{salary}
</Badge>
)}
{posted && (
<Badge variant="outline" className="gap-x-1">
<ClockIcon className="size-3" />
{posted}
</Badge>
)}
</div>
</motion.button>
);
}
function RouteComponent() {
const {
activeFilterChips,
@@ -144,7 +70,7 @@ function RouteComponent() {
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex max-w-xl flex-col items-center gap-y-4 py-12 text-center"
className="mx-auto flex max-w-xl flex-col items-center gap-y-4 py-12 text-center"
>
<MagnifyingGlassIcon className="size-12 text-muted-foreground" weight="light" />
<h2 className="text-lg font-medium">
@@ -230,10 +156,19 @@ function RouteComponent() {
{isPending && jobs.length === 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="flex animate-pulse flex-col gap-y-3 rounded-md border bg-card p-4">
<div className="h-4 w-3/4 rounded bg-muted" />
<div className="h-3 w-1/2 rounded bg-muted" />
<div className="h-3 w-5/6 rounded bg-muted" />
<div key={index} className="flex flex-col gap-y-3 rounded-md border bg-card p-4">
<div className="flex items-start gap-x-3">
<Skeleton className="size-10 shrink-0 rounded-md" />
<div className="flex flex-1 flex-col gap-y-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
<div className="flex gap-2">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
</div>
))}
</div>
@@ -32,10 +32,10 @@ export function ResumeCard({ resume }: ResumeCardProps) {
<ResumeContextMenu resume={resume}>
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
<motion.div
className="will-change-transform"
whileHover={{ y: -2, scale: 1.005 }}
whileTap={{ scale: 0.998 }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
style={{ willChange: "transform" }}
>
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
{match({ isLoading, imageSrc: screenshotData?.url })
@@ -71,8 +71,7 @@ function ResumeLockOverlay({ isLocked }: { isLocked: boolean }) {
animate={{ opacity: 0.6 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
style={{ willChange: "opacity" }}
className="absolute inset-0 flex items-center justify-center"
className="absolute inset-0 flex items-center justify-center will-change-[opacity]"
>
<div className="flex items-center justify-center rounded-full bg-popover p-6">
<LockSimpleIcon weight="thin" className="size-12 opacity-60" />
@@ -20,7 +20,7 @@ export function GridView({ resumes }: Props) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="will-change-[transform,opacity]"
>
<CreateResumeCard />
</motion.div>
@@ -30,7 +30,7 @@ export function GridView({ resumes }: Props) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="will-change-[transform,opacity]"
>
<ImportResumeCard />
</motion.div>
@@ -48,7 +48,7 @@ export function GridView({ resumes }: Props) {
filter: "blur(8px)",
}}
transition={{ duration: 0.2, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="will-change-[transform,opacity]"
>
<ResumeCard resume={resume} />
</motion.div>
@@ -32,11 +32,11 @@ export function ListView({ resumes }: Props) {
return (
<div className="flex flex-col gap-y-1">
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Button
size="lg"
@@ -56,11 +56,11 @@ export function ListView({ resumes }: Props) {
</motion.div>
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: 0.03, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Button
size="lg"
@@ -85,11 +85,11 @@ export function ListView({ resumes }: Props) {
<motion.div
layout
key={resume.id}
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.18, delay: Math.min(0.12, (index + 2) * 0.02), ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<ResumeListItem resume={resume} />
</motion.div>
+29 -19
View File
@@ -11,6 +11,7 @@ import { useMemo } from "react";
import z from "zod";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { orpc } from "@/integrations/orpc/client";
@@ -74,26 +75,35 @@ function RouteComponent() {
<Separator />
<div className="flex items-center gap-x-4">
<Combobox
value={sort}
options={sortOptions}
placeholder={t`Sort by`}
onValueChange={(value) => {
if (!value) return;
void navigate({ search: { tags, sort: value as SortOption } });
}}
/>
<div className="flex gap-2">
<Label>
<Trans>Sort by</Trans>
</Label>
<Combobox
value={sort}
options={sortOptions}
placeholder={t`Sort by`}
onValueChange={(value) => {
if (!value) return;
void navigate({ search: { tags, sort: value as SortOption } });
}}
/>
</div>
<Combobox
multiple
value={tags}
options={tagOptions}
placeholder={t`Filter by`}
className={cn({ hidden: tagOptions.length === 0 })}
onValueChange={(value) => {
void navigate({ search: { tags: value ?? [], sort } });
}}
/>
<div className={cn("flex gap-2", { hidden: tagOptions.length === 0 })}>
<Label>
<Trans>Filter by</Trans>
</Label>
<Combobox
multiple
value={tags}
options={tagOptions}
placeholder={t`Filter by`}
onValueChange={(value) => {
void navigate({ search: { tags: value ?? [], sort } });
}}
/>
</div>
<Tabs className="ltr:ms-auto rtl:me-auto" value={view} onValueChange={onViewChange}>
<TabsList>
+1 -2
View File
@@ -222,8 +222,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
<div className="rounded-md bg-primary/10 p-2.5">
+3 -5
View File
@@ -67,8 +67,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="flex items-start gap-4 rounded-md border bg-popover p-6">
<div className="rounded-md bg-primary/10 p-2.5">
@@ -116,12 +115,11 @@ function RouteComponent() {
{apiKeys.map((key, index) => (
<motion.div
key={key.id}
className="flex items-center gap-x-4 py-4"
className="flex items-center gap-x-4 py-4 will-change-[transform,opacity]"
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.16, delay: Math.min(0.12, index * 0.04) }}
style={{ willChange: "transform, opacity" }}
>
<KeyIcon />
@@ -133,10 +131,10 @@ function RouteComponent() {
</div>
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.03 }}
whileTap={{ scale: 0.96 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button size="icon" variant="ghost" onClick={() => onDelete(key.id)}>
<TrashSimpleIcon />
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { GithubLogoIcon, GoogleLogoIcon, PasswordIcon, VaultIcon } from "@phosphor-icons/react";
import { GithubLogoIcon, GoogleLogoIcon, LinkedinLogoIcon, PasswordIcon, VaultIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useCallback } from "react";
import { toast } from "sonner";
@@ -20,6 +20,7 @@ export function getProviderName(providerId: AuthProvider): string {
.with("credential", () => "Password")
.with("google", () => "Google")
.with("github", () => "GitHub")
.with("linkedin", () => "LinkedIn")
.with("custom", () => "Custom OAuth")
.exhaustive();
}
@@ -32,6 +33,7 @@ export function getProviderIcon(providerId: AuthProvider): ReactNode {
.with("credential", () => <PasswordIcon />)
.with("google", () => <GoogleLogoIcon />)
.with("github", () => <GithubLogoIcon />)
.with("linkedin", () => <LinkedinLogoIcon />)
.with("custom", () => <VaultIcon />)
.exhaustive();
}
@@ -100,7 +102,7 @@ export function useAuthProviderActions() {
/**
* Hook to get enabled social providers for the current user
* Possible values: "credential", "google", "github", "custom"
* Possible values: "credential", "google", "github", "linkedin", "custom"
*/
export function useEnabledProviders() {
const { data: enabledProviders = [] } = useQuery(orpc.auth.providers.list.queryOptions());
@@ -30,8 +30,7 @@ export function PasswordSection() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="flex items-center justify-between gap-x-4"
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
>
<h2 className="flex items-center gap-x-3 text-base font-medium">
<PasswordIcon />
@@ -41,10 +40,10 @@ export function PasswordSection() {
{match(hasPassword)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleUpdatePassword}>
<PencilSimpleLineIcon />
@@ -54,10 +53,10 @@ export function PasswordSection() {
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button
variant="outline"
@@ -38,10 +38,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
return (
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Separator />
@@ -54,10 +54,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
{match(isConnected)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleUnlink}>
<LinkBreakIcon />
@@ -67,10 +67,10 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleLink}>
<LinkIcon />
@@ -31,10 +31,10 @@ export function TwoFactorSection() {
return (
<motion.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
>
<Separator />
@@ -47,10 +47,10 @@ export function TwoFactorSection() {
{match(hasTwoFactor)
.with(true, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleTwoFactorAction}>
<ToggleLeftIcon />
@@ -60,10 +60,10 @@ export function TwoFactorSection() {
))
.with(false, () => (
<motion.div
className="will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.99 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="outline" onClick={handleTwoFactorAction}>
<ToggleRightIcon />
@@ -28,8 +28,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-4"
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
>
<PasswordSection />
@@ -46,8 +45,10 @@ function RouteComponent() {
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.6} />}
{"custom" in enabledProviders && (
<SocialProviderSection provider="custom" animationDelay={0.6} name={enabledProviders.custom} />
<SocialProviderSection provider="custom" animationDelay={0.7} name={enabledProviders.custom} />
)}
</motion.div>
</div>
@@ -63,8 +63,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<p className="leading-relaxed">
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
@@ -78,11 +77,10 @@ function RouteComponent() {
/>
<motion.div
className="justify-self-end"
className="justify-self-end will-change-transform"
whileHover={!isConfirmationValid ? undefined : { y: -1, scale: 1.01 }}
whileTap={!isConfirmationValid ? undefined : { scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ willChange: "transform" }}
>
<Button variant="destructive" onClick={handleDeleteAccount} disabled={!isConfirmationValid}>
<TrashSimpleIcon />
@@ -27,8 +27,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="grid gap-1.5">
<Label className="mb-0.5">
+2 -4
View File
@@ -124,8 +124,7 @@ function RouteComponent() {
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="grid max-w-xl gap-6"
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField
@@ -222,8 +221,7 @@ function RouteComponent() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ willChange: "transform, opacity" }}
className="flex items-center gap-x-4 justify-self-end"
className="flex items-center gap-x-4 justify-self-end will-change-[transform,opacity]"
>
<Button type="reset" variant="ghost" onClick={onCancel}>
<Trans>Cancel</Trans>
+2
View File
@@ -51,6 +51,8 @@ export const tailorOutputSchema = z.object({
),
}),
)
.optional()
.catch([])
.describe(
"Tailored role-level updates. Use an empty array when the experience does not have role progression.",
),
+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;
}
}

Some files were not shown because too many files have changed in this diff Show More