diff --git a/src/dialogs/resume/sections/award.tsx b/src/dialogs/resume/sections/award.tsx
index 8bb2f8469..5b891f715 100644
--- a/src/dialogs/resume/sections/award.tsx
+++ b/src/dialogs/resume/sections/award.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/certification.tsx b/src/dialogs/resume/sections/certification.tsx
index a464fb80d..c51cb3117 100644
--- a/src/dialogs/resume/sections/certification.tsx
+++ b/src/dialogs/resume/sections/certification.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/education.tsx b/src/dialogs/resume/sections/education.tsx
index c17767d5a..5e4db01b8 100644
--- a/src/dialogs/resume/sections/education.tsx
+++ b/src/dialogs/resume/sections/education.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/experience.tsx b/src/dialogs/resume/sections/experience.tsx
index 4f6234438..c00416bc7 100644
--- a/src/dialogs/resume/sections/experience.tsx
+++ b/src/dialogs/resume/sections/experience.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/interest.tsx b/src/dialogs/resume/sections/interest.tsx
index 0e040a522..816be1f4e 100644
--- a/src/dialogs/resume/sections/interest.tsx
+++ b/src/dialogs/resume/sections/interest.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/language.tsx b/src/dialogs/resume/sections/language.tsx
index bf6cf0865..9a8b3519e 100644
--- a/src/dialogs/resume/sections/language.tsx
+++ b/src/dialogs/resume/sections/language.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/profile.tsx b/src/dialogs/resume/sections/profile.tsx
index d21aa41e6..609ee21b5 100644
--- a/src/dialogs/resume/sections/profile.tsx
+++ b/src/dialogs/resume/sections/profile.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/project.tsx b/src/dialogs/resume/sections/project.tsx
index ed5c1c43b..0e3bf345a 100644
--- a/src/dialogs/resume/sections/project.tsx
+++ b/src/dialogs/resume/sections/project.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/publication.tsx b/src/dialogs/resume/sections/publication.tsx
index b8855f0d8..1e4577f29 100644
--- a/src/dialogs/resume/sections/publication.tsx
+++ b/src/dialogs/resume/sections/publication.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/reference.tsx b/src/dialogs/resume/sections/reference.tsx
index b54022942..fca3ace8c 100644
--- a/src/dialogs/resume/sections/reference.tsx
+++ b/src/dialogs/resume/sections/reference.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/skill.tsx b/src/dialogs/resume/sections/skill.tsx
index 8cea79717..624ae16bf 100644
--- a/src/dialogs/resume/sections/skill.tsx
+++ b/src/dialogs/resume/sections/skill.tsx
@@ -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();
};
diff --git a/src/dialogs/resume/sections/volunteer.tsx b/src/dialogs/resume/sections/volunteer.tsx
index fcb21d66c..43332636e 100644
--- a/src/dialogs/resume/sections/volunteer.tsx
+++ b/src/dialogs/resume/sections/volunteer.tsx
@@ -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();
};
diff --git a/src/dialogs/store.test.ts b/src/dialogs/store.test.ts
new file mode 100644
index 000000000..5d96c5334
--- /dev/null
+++ b/src/dialogs/store.test.ts
@@ -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);
+ }
+ });
+});
diff --git a/src/hooks/use-confirm.test.tsx b/src/hooks/use-confirm.test.tsx
new file mode 100644
index 000000000..5d15445f1
--- /dev/null
+++ b/src/hooks/use-confirm.test.tsx
@@ -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
{children};
+}
+
+/** A test component that triggers confirm and stores the result */
+function ConfirmTester() {
+ const confirm = useConfirm();
+ const [result, setResult] = React.useState
(null);
+
+ return (
+
+
+ {result !== null && {String(result)}}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("useConfirm", () => {
+ it("throws when used outside provider", () => {
+ expect(() => {
+ renderHook(() => useConfirm());
+ }).toThrow("useConfirm must be used within a ");
+ });
+
+ 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 | 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ await act(async () => {
+ screen.getByText("Trigger").click();
+ });
+
+ await act(async () => {
+ screen.getByText("Keep").click();
+ });
+
+ expect(screen.getByTestId("result").textContent).toBe("false");
+ });
+});
diff --git a/src/hooks/use-controlled-state.test.tsx b/src/hooks/use-controlled-state.test.tsx
new file mode 100644
index 000000000..1e55f5f53
--- /dev/null
+++ b/src/hooks/use-controlled-state.test.tsx
@@ -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({ 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);
+ });
+ });
+});
diff --git a/src/hooks/use-mobile.test.tsx b/src/hooks/use-mobile.test.tsx
new file mode 100644
index 000000000..0e0093801
--- /dev/null
+++ b/src/hooks/use-mobile.test.tsx
@@ -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)");
+ });
+});
diff --git a/src/hooks/use-prompt.test.tsx b/src/hooks/use-prompt.test.tsx
new file mode 100644
index 000000000..1a2804fa8
--- /dev/null
+++ b/src/hooks/use-prompt.test.tsx
@@ -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 {children};
+}
+
+function PromptTester() {
+ const prompt = usePrompt();
+ const [result, setResult] = React.useState(undefined);
+
+ return (
+
+
+ {result !== undefined && {result === null ? "null" : result}}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("usePrompt", () => {
+ it("throws when used outside provider", () => {
+ expect(() => {
+ renderHook(() => usePrompt());
+ }).toThrow("usePrompt must be used within a ");
+ });
+
+ 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 | undefined;
+ act(() => {
+ promise = result.current("Enter value");
+ });
+
+ expect(promise).toBeInstanceOf(Promise);
+ });
+
+ it("opens dialog with title, description, and default value", async () => {
+ render(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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");
+ });
+});
diff --git a/src/integrations/ai/store.test.ts b/src/integrations/ai/store.test.ts
new file mode 100644
index 000000000..f929aa699
--- /dev/null
+++ b/src/integrations/ai/store.test.ts
@@ -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");
+ });
+});
diff --git a/src/integrations/auth/client.ts b/src/integrations/auth/client.ts
index 139dcc610..e5e1a2e1d 100644
--- a/src/integrations/auth/client.ts
+++ b/src/integrations/auth/client.ts
@@ -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({
diff --git a/src/integrations/auth/config.ts b/src/integrations/auth/config.ts
index 793ca2364..f5b61d288 100644
--- a/src/integrations/auth/config.ts
+++ b/src/integrations/auth/config.ts
@@ -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" }),
diff --git a/src/integrations/auth/types.ts b/src/integrations/auth/types.ts
index 9ff160f77..af7c86bbd 100644
--- a/src/integrations/auth/types.ts
+++ b/src/integrations/auth/types.ts
@@ -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;
diff --git a/src/integrations/drizzle/schema.ts b/src/integrations/drizzle/schema.ts
index f569c709f..612e988c1 100644
--- a/src/integrations/drizzle/schema.ts
+++ b/src/integrations/drizzle/schema.ts
@@ -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()
diff --git a/src/integrations/import/json-resume.test.ts b/src/integrations/import/json-resume.test.ts
new file mode 100644
index 000000000..2baadcecb
--- /dev/null
+++ b/src/integrations/import/json-resume.test.ts
@@ -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) {
+ 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("A passionate developer.
");
+ expect(result.summary.hidden).toBe(false);
+ });
+
+ it("handles missing summary", () => {
+ const data = makeMinimalJsonResume();
+ delete (data.basics as Record).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("Led a team of 5.
");
+ expect(exp.description).toContain("Built CI/CD pipeline");
+ });
+
+ 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("Great work
");
+ });
+ });
+
+ 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("Great developer and team player
");
+ });
+
+ 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);
+ });
+ });
+});
diff --git a/src/integrations/import/json-resume.tsx b/src/integrations/import/json-resume.tsx
index 2334aadef..81706f97a 100644
--- a/src/integrations/import/json-resume.tsx
+++ b/src/integrations/import/json-resume.tsx
@@ -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;
-// 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(`${summary}
`);
- }
-
- if (highlights && highlights.length > 0) {
- parts.push("");
- for (const highlight of highlights) {
- parts.push(`- ${highlight}
`);
- }
- parts.push("
");
- }
-
- return parts.join("");
-}
-
-// Helper function to convert array to HTML list
-function arrayToHtmlList(items?: string[]): string {
- if (!items || items.length === 0) return "";
- return `${items.map((item) => `- ${item}
`).join("")}
`;
-}
-
// 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 = {
diff --git a/src/integrations/import/reactive-resume-json.test.ts b/src/integrations/import/reactive-resume-json.test.ts
new file mode 100644
index 000000000..6a3a6e80f
--- /dev/null
+++ b/src/integrations/import/reactive-resume-json.test.ts
@@ -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);
+ });
+});
diff --git a/src/integrations/import/reactive-resume-v4-json.test.ts b/src/integrations/import/reactive-resume-v4-json.test.ts
new file mode 100644
index 000000000..2e72c0aac
--- /dev/null
+++ b/src/integrations/import/reactive-resume-v4-json.test.ts
@@ -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) {
+ 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: "A developer
",
+ },
+ 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("A developer
");
+ 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: "Built things
",
+ 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("Built things
");
+ 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: "Graduated
",
+ 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: "Won it
",
+ },
+ ];
+
+ 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: "Research
" },
+ ];
+
+ 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: "Helped
",
+ },
+ ];
+
+ 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: "Great developer
" },
+ ];
+
+ 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("Great developer
");
+ });
+
+ 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: "summary
",
+ url: { label: "", href: "" },
+ },
+ ];
+
+ const result = importer.parse(JSON.stringify(v4));
+ expect(result.sections.projects.items[0].description).toBe("summary
");
+ });
+
+ 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 = "Summary text
";
+
+ 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 = "Summary text
";
+
+ 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: "Details
",
+ },
+ ],
+ },
+ };
+
+ 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"]);
+ });
+ });
+});
diff --git a/src/integrations/jobs/store.test.ts b/src/integrations/jobs/store.test.ts
new file mode 100644
index 000000000..ce2a87709
--- /dev/null
+++ b/src/integrations/jobs/store.test.ts
@@ -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();
+ });
+});
diff --git a/src/integrations/orpc/context.ts b/src/integrations/orpc/context.ts
index 9906b05b8..6640072f1 100644
--- a/src/integrations/orpc/context.ts
+++ b/src/integrations/orpc/context.ts
@@ -26,7 +26,8 @@ async function getUserFromBearerToken(headers: Headers): Promise {
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 {
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 {
if (!userResult) return null;
return userResult;
- } catch {
+ } catch (error) {
+ console.warn("API key verification failed:", error);
return null;
}
}
diff --git a/src/integrations/orpc/helpers/resume-access.test.ts b/src/integrations/orpc/helpers/resume-access.test.ts
new file mode 100644
index 000000000..d614a2fb4
--- /dev/null
+++ b/src/integrations/orpc/helpers/resume-access.test.ts
@@ -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",
+ );
+ });
+});
diff --git a/src/integrations/orpc/router/auth.ts b/src/integrations/orpc/router/auth.ts
index 164a1808d..5853a1e71 100644
--- a/src/integrations/orpc/router/auth.ts
+++ b/src/integrations/orpc/router/auth.ts
@@ -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 => {
diff --git a/src/integrations/orpc/services/ai.ts b/src/integrations/orpc/services/ai.ts
index 43e4c1dc6..8370298f5 100644
--- a/src/integrations/orpc/services/ai.ts
+++ b/src/integrations/orpc/services/ai.ts
@@ -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
diff --git a/src/integrations/orpc/services/auth.ts b/src/integrations/orpc/services/auth.ts
index a1b11cd9d..205bd5ad6 100644
--- a/src/integrations/orpc/services/auth.ts
+++ b/src/integrations/orpc/services/auth.ts
@@ -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;
diff --git a/src/integrations/orpc/services/printer.ts b/src/integrations/orpc/services/printer.ts
index c68d941c6..fb861e0f2 100644
--- a/src/integrations/orpc/services/printer.ts
+++ b/src/integrations/orpc/services/printer.ts
@@ -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>();
+const activeScreenshotJobs = new Map>();
+
// Singleton browser instance for connection reuse
let browserInstance: Browser | null = null;
@@ -59,15 +63,312 @@ async function closeBrowser(): Promise {
}
// 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, "id" | "data" | "userId">,
+): Promise {
+ 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, "userId" | "id" | "data" | "updatedAt">,
+): Promise {
+ 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