diff --git a/src/actions/recipient.actions.ts b/src/actions/recipient.actions.ts index e05515b..af8eebf 100644 --- a/src/actions/recipient.actions.ts +++ b/src/actions/recipient.actions.ts @@ -2,19 +2,26 @@ import { revalidatePath } from "next/cache" +import { getI18n } from "@/i18n/server" import { + buildCreateRecipientSchema, + buildUpdateRecipientSchema, type CreateRecipientFormType, - createRecipientSchema, type UpdateRecipientFormType, - updateRecipientSchema, } from "@/schemas/recipient.schema" import { createRecipientUseCase, updateRecipientUseCase, } from "@/use-cases/recipient.use-cases" +import { localizeRecipientFieldErrors } from "./recipient.messages" + export async function createNewRecipient(formData: CreateRecipientFormType) { - const validatedFields = createRecipientSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.recipients + const validatedFields = buildCreateRecipientSchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { @@ -27,25 +34,34 @@ export async function createNewRecipient(formData: CreateRecipientFormType) { const result = await createRecipientUseCase(validatedFields.data) if (!result.success) { - return result + return { + ...result, + errors: localizeRecipientFieldErrors(result.errors, copy.actions), + message: copy.actions.createFailure, + } } revalidatePath("/recipients") return { success: true, - message: "Recipient created successfully", + message: copy.actions.createSuccess, } } catch (error) { console.error("Database error:", error) return { - message: "Failed to create recipient", + success: false, + message: copy.actions.createFailure, } } } export async function updateRecipient(formData: UpdateRecipientFormType) { - const validatedFields = updateRecipientSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.recipients + const validatedFields = buildUpdateRecipientSchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { @@ -58,19 +74,24 @@ export async function updateRecipient(formData: UpdateRecipientFormType) { const result = await updateRecipientUseCase(validatedFields.data) if (!result.success) { - return result + return { + ...result, + errors: localizeRecipientFieldErrors(result.errors, copy.actions), + message: copy.actions.updateFailure, + } } revalidatePath("/recipients") return { success: true, - message: "Recipient updated successfully", + message: copy.actions.updateSuccess, } } catch (error) { console.error("Database error:", error) return { - message: "Failed to update recipient", + success: false, + message: copy.actions.updateFailure, } } } diff --git a/src/actions/recipient.messages.ts b/src/actions/recipient.messages.ts new file mode 100644 index 0000000..5dc925c --- /dev/null +++ b/src/actions/recipient.messages.ts @@ -0,0 +1,39 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +type RecipientActionCopy = Dictionary["inventory"]["recipients"]["actions"] + +type FieldErrors = Record + +const recipientErrorMessageKeys = { + "Username already exists": "duplicateUsername", + "Email already exists": "duplicateEmail", +} as const satisfies Record + +function isRecipientErrorMessage( + message: string, +): message is keyof typeof recipientErrorMessageKeys { + return message in recipientErrorMessageKeys +} + +function localizeRecipientMessage( + message: string, + copy: RecipientActionCopy, +): string { + if (!isRecipientErrorMessage(message)) return message + + return copy[recipientErrorMessageKeys[message]] +} + +export function localizeRecipientFieldErrors( + errors: FieldErrors | undefined, + copy: RecipientActionCopy, +): FieldErrors | undefined { + if (!errors) return undefined + + return Object.fromEntries( + Object.entries(errors).map(([field, messages]) => [ + field, + messages.map((message) => localizeRecipientMessage(message, copy)), + ]), + ) +} diff --git a/src/app/(dashboard)/recipients/[recipientId]/edit/page.tsx b/src/app/(dashboard)/recipients/[recipientId]/edit/page.tsx index d539580..833706e 100644 --- a/src/app/(dashboard)/recipients/[recipientId]/edit/page.tsx +++ b/src/app/(dashboard)/recipients/[recipientId]/edit/page.tsx @@ -26,6 +26,7 @@ export default async function RecipientEditPage({ initialData={recipient} mode="edit" formCopy={copy.form} + schemaCopy={copy.schema} departmentCopy={copy.departments} fallbackCopy={copy.fallback} submitButtonCopy={dictionary.common.submitButton} diff --git a/src/app/(dashboard)/recipients/_components/recipient.form.tsx b/src/app/(dashboard)/recipients/_components/recipient.form.tsx index 9be3ce7..e79fbfe 100644 --- a/src/app/(dashboard)/recipients/_components/recipient.form.tsx +++ b/src/app/(dashboard)/recipients/_components/recipient.form.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { useRouter } from "next/navigation" +import { useMemo } from "react" import { useForm } from "react-hook-form" import { toast } from "sonner" import { @@ -14,8 +15,10 @@ import { } from "@/components/forms/submitButton" import { RECIPIENT_DEPARTMENTS } from "@/lib/constants" import { + buildCreateRecipientSchema, + buildUpdateRecipientSchema, type CreateRecipientFormType, - recipientSchema, + type RecipientSchemaCopy, type UpdateRecipientFormType, } from "@/schemas/recipient.schema" import type { Recipient } from "@/types" @@ -31,6 +34,7 @@ interface RecipientFormProps { initialData?: Recipient mode?: "create" | "edit" formCopy: RecipientFormCopy + schemaCopy: RecipientSchemaCopy departmentCopy: RecipientDepartmentCopy fallbackCopy: RecipientFallbackCopy submitButtonCopy: SubmitButtonCopy @@ -40,11 +44,19 @@ export default function RecipientForm({ initialData, mode = "create", formCopy, + schemaCopy, departmentCopy, fallbackCopy, submitButtonCopy, }: RecipientFormProps) { const router = useRouter() + const schema = useMemo( + () => + mode === "create" + ? buildCreateRecipientSchema(schemaCopy) + : buildUpdateRecipientSchema(schemaCopy), + [mode, schemaCopy], + ) const { register, @@ -52,7 +64,7 @@ export default function RecipientForm({ setError, formState: { errors, isSubmitting, isSubmitSuccessful }, } = useForm({ - resolver: zodResolver(recipientSchema), + resolver: zodResolver(schema), defaultValues: { id: initialData?.id || "", username: initialData?.username || "", diff --git a/src/app/(dashboard)/recipients/new/page.tsx b/src/app/(dashboard)/recipients/new/page.tsx index 0d9bedb..037f7da 100644 --- a/src/app/(dashboard)/recipients/new/page.tsx +++ b/src/app/(dashboard)/recipients/new/page.tsx @@ -14,6 +14,7 @@ export default async function NewRecipientPage() { { +export type RecipientSchemaCopy = + Dictionary["inventory"]["recipients"]["schema"] + +const defaultRecipientSchemaCopy: RecipientSchemaCopy = { + usernameRequired: "Username is required", + firstNameRequired: "First name is required", + lastNameRequired: "Last name is required", + departmentRequired: "Department is required", + emailInvalid: "Email format is invalid", + idRequired: "ID is required", +} + +const recipientDepartments = [ + "IT", + "ENGINEERING", + "TRAFFIC", + "DRIVER", + "LOGISTICS", + "ADMINISTRATION", + "SALES", + "OTHER", +] as const + +function buildRecipientBaseSchema(copy: RecipientSchemaCopy) { + return z.object({ + id: z.string().optional(), + username: z.string().min(1, { + error: copy.usernameRequired, + }), + firstName: z.string().min(1, { + error: copy.firstNameRequired, + }), + lastName: z.string().min(1, { + error: copy.lastNameRequired, + }), + department: z.enum(recipientDepartments, { + error: copy.departmentRequired, + }), + email: z.string().optional().nullable(), + phone: z.string().optional().nullable(), + }) +} + +export const recipientSchema = buildRecipientBaseSchema( + defaultRecipientSchemaCopy, +) + +export function buildCreateRecipientSchema(copy: RecipientSchemaCopy) { + return buildRecipientBaseSchema(copy).superRefine((data, ctx) => { if (data.email && !z.string().email().safeParse(data.email).success) { ctx.addIssue({ code: "custom", - message: "Email format is invalid", + message: copy.emailInvalid, path: ["email"], }) } - }, + }) +} + +export const createRecipientSchema = buildCreateRecipientSchema( + defaultRecipientSchemaCopy, ) export type CreateRecipientFormType = z.infer -export const updateRecipientSchema = recipientSchema.extend({ - id: z.string().nonempty("ID is required"), -}) +export function buildUpdateRecipientSchema(copy: RecipientSchemaCopy) { + return buildRecipientBaseSchema(copy).extend({ + id: z.string().nonempty(copy.idRequired), + }) +} + +export const updateRecipientSchema = buildUpdateRecipientSchema( + defaultRecipientSchemaCopy, +) export type UpdateRecipientFormType = z.infer diff --git a/tests/unit/actions/recipient.actions.test.ts b/tests/unit/actions/recipient.actions.test.ts new file mode 100644 index 0000000..a70e6a3 --- /dev/null +++ b/tests/unit/actions/recipient.actions.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { es } from "@/i18n/dictionaries/es" + +const mocks = vi.hoisted(() => ({ + revalidatePath: vi.fn(), + getI18n: vi.fn(), + createRecipientUseCase: vi.fn(), + updateRecipientUseCase: vi.fn(), +})) + +vi.mock("next/cache", () => ({ + revalidatePath: mocks.revalidatePath, +})) + +vi.mock("@/i18n/server", () => ({ + getI18n: mocks.getI18n, +})) + +vi.mock("@/use-cases/recipient.use-cases", () => ({ + createRecipientUseCase: mocks.createRecipientUseCase, + updateRecipientUseCase: mocks.updateRecipientUseCase, +})) + +import { + createNewRecipient, + updateRecipient, +} from "@/actions/recipient.actions" + +describe("recipient actions localization", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) + }) + + it("returns localized schema validation errors for invalid create input", async () => { + const result = await createNewRecipient({ + username: "", + firstName: "", + lastName: "", + department: "", + email: "not-an-email", + } as unknown as Parameters[0]) + + expect(mocks.getI18n).toHaveBeenCalledOnce() + expect(mocks.createRecipientUseCase).not.toHaveBeenCalled() + expect(result).toEqual({ + success: false, + errors: { + username: [es.inventory.recipients.schema.usernameRequired], + firstName: [es.inventory.recipients.schema.firstNameRequired], + lastName: [es.inventory.recipients.schema.lastNameRequired], + department: [es.inventory.recipients.schema.departmentRequired], + }, + }) + }) + + it("localizes mapped duplicate field errors for create failures", async () => { + mocks.createRecipientUseCase.mockResolvedValue({ + success: false, + errors: { + username: ["Username already exists"], + email: ["Email already exists"], + }, + }) + + const result = await createNewRecipient({ + username: "ada", + firstName: "Ada", + lastName: "Lovelace", + department: "ENGINEERING", + email: "ada@example.test", + }) + + expect(result).toEqual({ + success: false, + errors: { + username: [es.inventory.recipients.actions.duplicateUsername], + email: [es.inventory.recipients.actions.duplicateEmail], + }, + message: es.inventory.recipients.actions.createFailure, + }) + }) + + it("returns a localized update success message and revalidates recipients", async () => { + mocks.updateRecipientUseCase.mockResolvedValue({ success: true }) + + const result = await updateRecipient({ + id: "recipient-1", + username: "ada", + firstName: "Ada", + lastName: "Lovelace", + department: "ENGINEERING", + email: "ada@example.test", + }) + + expect(result).toEqual({ + success: true, + message: es.inventory.recipients.actions.updateSuccess, + }) + expect(mocks.revalidatePath).toHaveBeenCalledWith("/recipients") + }) +}) diff --git a/tests/unit/actions/recipient.messages.test.ts b/tests/unit/actions/recipient.messages.test.ts new file mode 100644 index 0000000..7d5eef6 --- /dev/null +++ b/tests/unit/actions/recipient.messages.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest" + +import { localizeRecipientFieldErrors } from "@/actions/recipient.messages" + +const actionCopy = { + createSuccess: "Destinatario creado correctamente", + createFailure: "Error al crear el destinatario", + updateSuccess: "Destinatario actualizado correctamente", + updateFailure: "Error al actualizar el destinatario", + duplicateUsername: "El nombre de usuario ya existe", + duplicateEmail: "El correo electrónico ya existe", +} + +describe("recipient action message localization", () => { + it("localizes known recipient field errors", () => { + expect( + localizeRecipientFieldErrors( + { + username: ["Username already exists"], + email: ["Email already exists"], + }, + actionCopy, + ), + ).toEqual({ + username: [actionCopy.duplicateUsername], + email: [actionCopy.duplicateEmail], + }) + }) + + it("keeps unknown messages unchanged", () => { + expect( + localizeRecipientFieldErrors( + { username: ["Unexpected recipient issue"] }, + actionCopy, + ), + ).toEqual({ username: ["Unexpected recipient issue"] }) + }) +}) diff --git a/tests/unit/app/recipients/recipient-form-wiring.test.ts b/tests/unit/app/recipients/recipient-form-wiring.test.ts new file mode 100644 index 0000000..d6f07ea --- /dev/null +++ b/tests/unit/app/recipients/recipient-form-wiring.test.ts @@ -0,0 +1,82 @@ +import { createElement } from "react" +import { renderToStaticMarkup } from "react-dom/server" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { en } from "@/i18n/dictionaries/en" +import { es } from "@/i18n/dictionaries/es" + +const mocks = vi.hoisted(() => ({ + getI18n: vi.fn(), + findById: vi.fn(), + recipientForm: vi.fn(), +})) + +vi.mock("@/i18n/server", () => ({ + getI18n: mocks.getI18n, +})) + +vi.mock("@/services/recipient.service", () => ({ + RecipientService: { + findById: mocks.findById, + }, +})) + +vi.mock("@/app/(dashboard)/recipients/_components/recipient.form", () => ({ + default: (props: unknown) => { + mocks.recipientForm(props) + return createElement("div", null, "Recipient form") + }, +})) + +describe("recipient form schema wiring", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("passes server-resolved schema copy into the new recipient form boundary", async () => { + mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) + + const { default: NewRecipientPage } = await import( + "@/app/(dashboard)/recipients/new/page" + ) + + renderToStaticMarkup(await NewRecipientPage()) + + expect(mocks.recipientForm).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "create", + schemaCopy: es.inventory.recipients.schema, + }), + ) + }) + + it("passes server-resolved schema copy into the edit recipient form boundary", async () => { + mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" }) + mocks.findById.mockResolvedValue({ + id: "recipient-1", + username: "ada", + firstName: "Ada", + lastName: "Lovelace", + department: "ENGINEERING", + email: "ada@example.test", + phone: "1234", + }) + + const { default: RecipientEditPage } = await import( + "@/app/(dashboard)/recipients/[recipientId]/edit/page" + ) + + renderToStaticMarkup( + await RecipientEditPage({ + params: Promise.resolve({ recipientId: "recipient-1" }), + }), + ) + + expect(mocks.recipientForm).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "edit", + schemaCopy: en.inventory.recipients.schema, + }), + ) + }) +}) diff --git a/tests/unit/i18n/dictionaries.test.ts b/tests/unit/i18n/dictionaries.test.ts index 0cfe626..d2e6b99 100644 --- a/tests/unit/i18n/dictionaries.test.ts +++ b/tests/unit/i18n/dictionaries.test.ts @@ -623,6 +623,22 @@ describe("i18n dictionaries", () => { SALES: "Sales", OTHER: "Other", }, + actions: { + createSuccess: "Recipient created successfully", + createFailure: "Failed to create recipient", + updateSuccess: "Recipient updated successfully", + updateFailure: "Failed to update recipient", + duplicateUsername: "Username already exists", + duplicateEmail: "Email already exists", + }, + schema: { + usernameRequired: "Username is required", + firstNameRequired: "First name is required", + lastNameRequired: "Last name is required", + departmentRequired: "Department is required", + emailInvalid: "Email format is invalid", + idRequired: "ID is required", + }, }) expect(getDictionary("es").inventory.recipients).toEqual({ @@ -688,6 +704,22 @@ describe("i18n dictionaries", () => { SALES: "Ventas", OTHER: "Otro", }, + actions: { + createSuccess: "Destinatario creado correctamente", + createFailure: "Error al crear el destinatario", + updateSuccess: "Destinatario actualizado correctamente", + updateFailure: "Error al actualizar el destinatario", + duplicateUsername: "El nombre de usuario ya existe", + duplicateEmail: "El correo electrónico ya existe", + }, + schema: { + usernameRequired: "El usuario es obligatorio", + firstNameRequired: "El nombre es obligatorio", + lastNameRequired: "El apellido es obligatorio", + departmentRequired: "El departamento es obligatorio", + emailInvalid: "El correo electrónico no es válido", + idRequired: "El ID es obligatorio", + }, }) }) diff --git a/tests/unit/schemas/recipient.schema.test.ts b/tests/unit/schemas/recipient.schema.test.ts new file mode 100644 index 0000000..21b68bc --- /dev/null +++ b/tests/unit/schemas/recipient.schema.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest" + +import { + buildCreateRecipientSchema, + buildUpdateRecipientSchema, +} from "@/schemas/recipient.schema" + +const schemaCopy = { + usernameRequired: "El usuario es obligatorio", + firstNameRequired: "El nombre es obligatorio", + lastNameRequired: "El apellido es obligatorio", + departmentRequired: "El departamento es obligatorio", + emailInvalid: "El correo electrónico no es válido", + idRequired: "El ID es obligatorio", +} + +describe("recipient schema localization", () => { + it("uses localized required-field validation messages for create", () => { + const result = buildCreateRecipientSchema(schemaCopy).safeParse({ + username: "", + firstName: "", + lastName: "", + department: "", + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + + expect(errors.username).toContain(schemaCopy.usernameRequired) + expect(errors.firstName).toContain(schemaCopy.firstNameRequired) + expect(errors.lastName).toContain(schemaCopy.lastNameRequired) + expect(errors.department).toContain(schemaCopy.departmentRequired) + } + }) + + it("uses a localized invalid email message for create", () => { + const result = buildCreateRecipientSchema(schemaCopy).safeParse({ + username: "ada", + firstName: "Ada", + lastName: "Lovelace", + department: "ENGINEERING", + email: "not-an-email", + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.email).toContain( + schemaCopy.emailInvalid, + ) + } + }) + + it("uses localized update identifier validation messages", () => { + const result = buildUpdateRecipientSchema(schemaCopy).safeParse({ + id: "", + username: "ada", + firstName: "Ada", + lastName: "Lovelace", + department: "ENGINEERING", + email: "ada@example.test", + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.id).toContain( + schemaCopy.idRequired, + ) + } + }) + + it("preserves canonical department values and optional empty email semantics", () => { + const result = buildCreateRecipientSchema(schemaCopy).safeParse({ + username: "ada", + firstName: "Ada", + lastName: "Lovelace", + department: "ENGINEERING", + email: "", + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.department).toBe("ENGINEERING") + expect(result.data.email).toBe("") + } + }) +})