diff --git a/src/actions/user.actions.ts b/src/actions/user.actions.ts index 1a070d3..90143bc 100644 --- a/src/actions/user.actions.ts +++ b/src/actions/user.actions.ts @@ -1,17 +1,17 @@ "use server" import { revalidatePath } from "next/cache" -import { flattenError } from "zod" +import { getI18n } from "@/i18n/server" import { + buildCreateUserSchema, + buildResetUserPasswordSchema, + buildSetUserActiveSchema, + buildUpdateUserSchema, type CreateUserFormType, - createUserSchema, type ResetUserPasswordFormType, - resetUserPasswordSchema, type SetUserActiveFormType, - setUserActiveSchema, type UpdateUserFormType, - updateUserSchema, } from "@/schemas/user.schema" import { requireRole } from "@/services/auth.service" import { @@ -21,12 +21,14 @@ import { updateUserUseCase, } from "@/use-cases/user.use-cases" +import { localizeUserFieldErrors } from "./user.messages" + const USERS_PATH = "/admin/users" export async function createUserAction(formData: CreateUserFormType) { - await requireRole("ADMIN") - - const validatedFields = createUserSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.admin.users + const validatedFields = buildCreateUserSchema(copy.schema).safeParse(formData) if (!validatedFields.success) { return { @@ -39,27 +41,32 @@ export async function createUserAction(formData: CreateUserFormType) { const result = await createUserUseCase(validatedFields.data) if (!result.success) { - return result + return { + ...result, + errors: localizeUserFieldErrors(result.errors, copy.actions), + message: copy.actions.createFailure, + } } revalidatePath(USERS_PATH) - return { success: true, message: "User created successfully" } + return { success: true, message: copy.actions.createSuccess } } catch (error) { console.error("Database error:", error) - return { success: false, message: "Failed to create user" } + return { success: false, message: copy.actions.createFailure } } } export async function updateUserAction(formData: UpdateUserFormType) { const session = await requireRole("ADMIN") - - const validatedFields = updateUserSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.admin.users + const validatedFields = buildUpdateUserSchema(copy.schema).safeParse(formData) if (!validatedFields.success) { return { success: false, - errors: flattenError(validatedFields.error).fieldErrors, + errors: validatedFields.error.flatten().fieldErrors, } } @@ -70,27 +77,34 @@ export async function updateUserAction(formData: UpdateUserFormType) { }) if (!result.success) { - return result + return { + ...result, + errors: localizeUserFieldErrors(result.errors, copy.actions), + message: copy.actions.updateFailure, + } } revalidatePath(USERS_PATH) - return { success: true, message: "User updated successfully" } + return { success: true, message: copy.actions.updateSuccess } } catch (error) { console.error("Database error:", error) - return { success: false, message: "Failed to update user" } + return { success: false, message: copy.actions.updateFailure } } } export async function setUserActiveAction(formData: SetUserActiveFormType) { const session = await requireRole("ADMIN") - - const validatedFields = setUserActiveSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.admin.users + const validatedFields = buildSetUserActiveSchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { success: false, - errors: flattenError(validatedFields.error).fieldErrors, + errors: validatedFields.error.flatten().fieldErrors, } } @@ -101,24 +115,30 @@ export async function setUserActiveAction(formData: SetUserActiveFormType) { }) if (!result.success) { - return result + return { + ...result, + errors: localizeUserFieldErrors(result.errors, copy.actions), + message: copy.actions.toggleStatusFailure, + } } revalidatePath(USERS_PATH) - return { success: true, message: "User status updated successfully" } + return { success: true, message: copy.actions.toggleStatusSuccess } } catch (error) { console.error("Database error:", error) - return { success: false, message: "Failed to update user status" } + return { success: false, message: copy.actions.toggleStatusFailure } } } export async function resetUserPasswordAction( formData: ResetUserPasswordFormType, ) { - await requireRole("ADMIN") - - const validatedFields = resetUserPasswordSchema.safeParse(formData) + const { dictionary } = await getI18n() + const copy = dictionary.admin.users + const validatedFields = buildResetUserPasswordSchema(copy.schema).safeParse( + formData, + ) if (!validatedFields.success) { return { @@ -131,14 +151,18 @@ export async function resetUserPasswordAction( const result = await resetUserPasswordUseCase(validatedFields.data) if (!result.success) { - return result + return { + ...result, + errors: localizeUserFieldErrors(result.errors, copy.actions), + message: copy.actions.resetPasswordFailure, + } } revalidatePath(USERS_PATH) - return { success: true, message: "Password reset successfully" } + return { success: true, message: copy.actions.resetPasswordSuccess } } catch (error) { console.error("Database error:", error) - return { success: false, message: "Failed to reset password" } + return { success: false, message: copy.actions.resetPasswordFailure } } } diff --git a/src/actions/user.messages.ts b/src/actions/user.messages.ts new file mode 100644 index 0000000..7e653d6 --- /dev/null +++ b/src/actions/user.messages.ts @@ -0,0 +1,40 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +type UserActionCopy = Dictionary["admin"]["users"]["actions"] + +type FieldErrors = Record + +const userErrorMessageKeys = { + "Username already exists": "duplicateUsername", + "Email already exists": "duplicateEmail", + "User not found": "notFound", + "Cannot remove access from the last active administrator": "lastActiveAdmin", + "You cannot remove your own administrator access": "selfAdminAccess", + "You cannot deactivate your own user": "selfDeactivate", +} as const satisfies Record + +function isUserErrorMessage( + message: string, +): message is keyof typeof userErrorMessageKeys { + return message in userErrorMessageKeys +} + +function localizeUserMessage(message: string, copy: UserActionCopy): string { + if (!isUserErrorMessage(message)) return message + + return copy[userErrorMessageKeys[message]] +} + +export function localizeUserFieldErrors( + errors: FieldErrors | undefined, + copy: UserActionCopy, +): FieldErrors | undefined { + if (!errors) return undefined + + return Object.fromEntries( + Object.entries(errors).map(([field, messages]) => [ + field, + messages.map((message) => localizeUserMessage(message, copy)), + ]), + ) +} diff --git a/src/app/(dashboard)/admin/users/_components/user.copy.ts b/src/app/(dashboard)/admin/users/_components/user.copy.ts new file mode 100644 index 0000000..60e3e49 --- /dev/null +++ b/src/app/(dashboard)/admin/users/_components/user.copy.ts @@ -0,0 +1,11 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +export type UserListCopy = Dictionary["admin"]["users"]["list"] +export type UserFormCopy = Dictionary["admin"]["users"]["form"] +export type UserResetPasswordCopy = + Dictionary["admin"]["users"]["resetPassword"] +export type UserRolesCopy = Dictionary["admin"]["users"]["roles"] +export type UserStatusCopy = Dictionary["admin"]["users"]["status"] +export type UserActionCopy = Dictionary["admin"]["users"]["actions"] +export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"] +export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"] diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index c7b1412..68643df 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -432,6 +432,88 @@ export const en = { }, }, }, + admin: { + users: { + list: { + title: "Users", + empty: "No users found.", + columns: { + name: "Name", + username: "Username", + email: "Email", + role: "Role", + status: "Status", + actions: "Actions", + }, + actions: { + edit: "Edit user", + }, + }, + new: { + title: "New User", + }, + edit: { + title: "Edit User", + }, + form: { + nameLabel: "Name", + namePlaceholder: "Full name", + usernameLabel: "Username", + usernamePlaceholder: "Username", + emailLabel: "Email", + emailPlaceholder: "user@example.com", + passwordLabel: "Password", + passwordPlaceholder: "Minimum 8 characters", + roleLabel: "Role", + activeLabel: "Active user", + createSubmit: "Create User", + updateSubmit: "Update User", + }, + resetPassword: { + title: "Reset password", + passwordLabel: "New password", + passwordPlaceholder: "Minimum 8 characters", + submit: "Reset Password", + }, + roles: { + ADMIN: "Admin", + MANAGER: "Manager", + STAFF: "Staff", + VIEWER: "Viewer", + }, + status: { + active: "Active", + inactive: "Inactive", + }, + actions: { + createSuccess: "User created successfully", + createFailure: "Failed to create user", + updateSuccess: "User updated successfully", + updateFailure: "Failed to update user", + toggleStatusSuccess: "User status updated successfully", + toggleStatusFailure: "Failed to update user status", + resetPasswordSuccess: "Password reset successfully", + resetPasswordFailure: "Failed to reset password", + duplicateUsername: "Username already exists", + duplicateEmail: "Email already exists", + notFound: "User not found", + lastActiveAdmin: + "Cannot remove access from the last active administrator", + selfAdminAccess: "You cannot remove your own administrator access", + selfDeactivate: "You cannot deactivate your own user", + }, + schema: { + usernameRequired: "Username is required", + nameRequired: "Name is required", + emailInvalid: "Invalid email", + passwordMinLength: "Password must be at least 8 characters", + userIdRequired: "User id is required", + }, + fallback: { + unknownRole: "Unknown role", + }, + }, + }, login: { title: "Sign In", usernameLabel: "Username", diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index 0e1c819..4a3d768 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -437,6 +437,88 @@ export const es = { }, }, }, + admin: { + users: { + list: { + title: "Usuarios", + empty: "No se encontraron usuarios.", + columns: { + name: "Nombre", + username: "Usuario", + email: "Correo electrónico", + role: "Rol", + status: "Estado", + actions: "Acciones", + }, + actions: { + edit: "Editar usuario", + }, + }, + new: { + title: "Nuevo usuario", + }, + edit: { + title: "Editar usuario", + }, + form: { + nameLabel: "Nombre", + namePlaceholder: "Nombre completo", + usernameLabel: "Usuario", + usernamePlaceholder: "Usuario", + emailLabel: "Correo electrónico", + emailPlaceholder: "usuario@ejemplo.com", + passwordLabel: "Contraseña", + passwordPlaceholder: "Mínimo 8 caracteres", + roleLabel: "Rol", + activeLabel: "Usuario activo", + createSubmit: "Crear usuario", + updateSubmit: "Actualizar usuario", + }, + resetPassword: { + title: "Restablecer contraseña", + passwordLabel: "Nueva contraseña", + passwordPlaceholder: "Mínimo 8 caracteres", + submit: "Restablecer contraseña", + }, + roles: { + ADMIN: "Administrador", + MANAGER: "Gerente", + STAFF: "Personal", + VIEWER: "Visor", + }, + status: { + active: "Activo", + inactive: "Inactivo", + }, + actions: { + createSuccess: "Usuario creado correctamente", + createFailure: "Error al crear el usuario", + updateSuccess: "Usuario actualizado correctamente", + updateFailure: "Error al actualizar el usuario", + toggleStatusSuccess: "Estado del usuario actualizado correctamente", + toggleStatusFailure: "Error al actualizar el estado del usuario", + resetPasswordSuccess: "Contraseña restablecida correctamente", + resetPasswordFailure: "Error al restablecer la contraseña", + duplicateUsername: "El nombre de usuario ya existe", + duplicateEmail: "El correo electrónico ya existe", + notFound: "Usuario no encontrado", + lastActiveAdmin: + "No se puede eliminar el acceso del último administrador activo", + selfAdminAccess: "No puedes eliminar tu propio acceso de administrador", + selfDeactivate: "No puedes desactivar tu propio usuario", + }, + schema: { + usernameRequired: "El usuario es obligatorio", + nameRequired: "El nombre es obligatorio", + emailInvalid: "Correo electrónico no válido", + passwordMinLength: "La contraseña debe tener al menos 8 caracteres", + userIdRequired: "El ID de usuario es obligatorio", + }, + fallback: { + unknownRole: "Rol desconocido", + }, + }, + }, login: { title: "Iniciar sesión", usernameLabel: "Usuario", diff --git a/src/schemas/user.schema.ts b/src/schemas/user.schema.ts index 9384159..ac0bffe 100644 --- a/src/schemas/user.schema.ts +++ b/src/schemas/user.schema.ts @@ -1,38 +1,70 @@ import { z } from "zod" +import type { Dictionary } from "@/i18n/dictionaries" + +export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"] + +export const defaultUserSchemaCopy: UserSchemaCopy = { + usernameRequired: "Username is required", + nameRequired: "Name is required", + emailInvalid: "Invalid email", + passwordMinLength: "Password must be at least 8 characters", + userIdRequired: "User id is required", +} + export const userRoleSchema = z.enum(["ADMIN", "MANAGER", "STAFF", "VIEWER"]) -const passwordSchema = z - .string() - .min(8, { error: "Password must be at least 8 characters" }) +function buildUserPasswordSchema(copy: UserSchemaCopy) { + return z.string().min(8, { error: copy.passwordMinLength }) +} -export const createUserSchema = z.object({ - username: z.string().trim().min(1, { error: "Username is required" }), - name: z.string().trim().min(1, { error: "Name is required" }), - email: z.email({ error: "Invalid email" }), - password: passwordSchema, - role: userRoleSchema, - isActive: z.boolean(), -}) +export function buildCreateUserSchema(copy: UserSchemaCopy) { + return z.object({ + username: z.string().trim().min(1, { error: copy.usernameRequired }), + name: z.string().trim().min(1, { error: copy.nameRequired }), + email: z.email({ error: copy.emailInvalid }), + password: buildUserPasswordSchema(copy), + role: userRoleSchema, + isActive: z.boolean(), + }) +} -export const updateUserSchema = z.object({ - id: z.string().min(1, { error: "User id is required" }), - username: z.string().trim().min(1, { error: "Username is required" }), - name: z.string().trim().min(1, { error: "Name is required" }), - email: z.email({ error: "Invalid email" }), - role: userRoleSchema, - isActive: z.boolean(), -}) +export const createUserSchema = buildCreateUserSchema(defaultUserSchemaCopy) -export const setUserActiveSchema = z.object({ - id: z.string().min(1, { error: "User id is required" }), - isActive: z.boolean(), -}) +export function buildUpdateUserSchema(copy: UserSchemaCopy) { + return z.object({ + id: z.string().min(1, { error: copy.userIdRequired }), + username: z.string().trim().min(1, { error: copy.usernameRequired }), + name: z.string().trim().min(1, { error: copy.nameRequired }), + email: z.email({ error: copy.emailInvalid }), + role: userRoleSchema, + isActive: z.boolean(), + }) +} -export const resetUserPasswordSchema = z.object({ - id: z.string().min(1, { error: "User id is required" }), - password: passwordSchema, -}) +export const updateUserSchema = buildUpdateUserSchema(defaultUserSchemaCopy) + +export function buildSetUserActiveSchema(copy: UserSchemaCopy) { + return z.object({ + id: z.string().min(1, { error: copy.userIdRequired }), + isActive: z.boolean(), + }) +} + +export const setUserActiveSchema = buildSetUserActiveSchema( + defaultUserSchemaCopy, +) + +export function buildResetUserPasswordSchema(copy: UserSchemaCopy) { + return z.object({ + id: z.string().min(1, { error: copy.userIdRequired }), + password: buildUserPasswordSchema(copy), + }) +} + +export const resetUserPasswordSchema = buildResetUserPasswordSchema( + defaultUserSchemaCopy, +) export type CreateUserFormType = z.infer export type UpdateUserFormType = z.infer diff --git a/tests/unit/actions/user.actions.test.ts b/tests/unit/actions/user.actions.test.ts new file mode 100644 index 0000000..eea21c8 --- /dev/null +++ b/tests/unit/actions/user.actions.test.ts @@ -0,0 +1,306 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { es } from "@/i18n/dictionaries/es" + +const mocks = vi.hoisted(() => ({ + revalidatePath: vi.fn(), + getI18n: vi.fn(), + requireRole: vi.fn(), + createUserUseCase: vi.fn(), + updateUserUseCase: vi.fn(), + setUserActiveUseCase: vi.fn(), + resetUserPasswordUseCase: vi.fn(), +})) + +vi.mock("next/cache", () => ({ + revalidatePath: mocks.revalidatePath, +})) + +vi.mock("@/i18n/server", () => ({ + getI18n: mocks.getI18n, +})) + +vi.mock("@/services/auth.service", () => ({ + requireRole: mocks.requireRole, +})) + +vi.mock("@/use-cases/user.use-cases", () => ({ + createUserUseCase: mocks.createUserUseCase, + updateUserUseCase: mocks.updateUserUseCase, + setUserActiveUseCase: mocks.setUserActiveUseCase, + resetUserPasswordUseCase: mocks.resetUserPasswordUseCase, +})) + +import { + createUserAction, + resetUserPasswordAction, + setUserActiveAction, + updateUserAction, +} from "@/actions/user.actions" + +describe("user actions localization", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) + mocks.requireRole.mockResolvedValue({ + user: { id: "admin-1" }, + }) + }) + + describe("createUserAction", () => { + it("returns localized schema validation errors for invalid create input", async () => { + const result = await createUserAction({ + username: "", + name: "", + email: "bad", + password: "short", + role: "ADMIN", + isActive: true, + }) + + expect(mocks.getI18n).toHaveBeenCalledOnce() + expect(mocks.createUserUseCase).not.toHaveBeenCalled() + expect(result).toEqual({ + success: false, + errors: { + username: [es.admin.users.schema.usernameRequired], + name: [es.admin.users.schema.nameRequired], + email: [es.admin.users.schema.emailInvalid], + password: [es.admin.users.schema.passwordMinLength], + }, + }) + }) + + it("localizes mapped duplicate field errors for create failures", async () => { + mocks.createUserUseCase.mockResolvedValue({ + success: false, + errors: { + username: ["Username already exists"], + email: ["Email already exists"], + }, + }) + + const result = await createUserAction({ + username: "ada", + name: "Ada", + email: "ada@example.test", + password: "password1", + role: "STAFF", + isActive: true, + }) + + expect(result).toEqual({ + success: false, + errors: { + username: [es.admin.users.actions.duplicateUsername], + email: [es.admin.users.actions.duplicateEmail], + }, + message: es.admin.users.actions.createFailure, + }) + }) + + it("returns a localized create success message and revalidates", async () => { + mocks.createUserUseCase.mockResolvedValue({ success: true }) + + const result = await createUserAction({ + username: "ada", + name: "Ada", + email: "ada@example.test", + password: "password1", + role: "STAFF", + isActive: true, + }) + + expect(result).toEqual({ + success: true, + message: es.admin.users.actions.createSuccess, + }) + expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users") + }) + }) + + describe("updateUserAction", () => { + it("returns localized schema validation errors for invalid update input", async () => { + const result = await updateUserAction({ + id: "", + username: "", + name: "", + email: "bad", + role: "ADMIN", + isActive: true, + }) + + expect(mocks.getI18n).toHaveBeenCalledOnce() + expect(mocks.updateUserUseCase).not.toHaveBeenCalled() + expect(result).toEqual({ + success: false, + errors: { + id: [es.admin.users.schema.userIdRequired], + username: [es.admin.users.schema.usernameRequired], + name: [es.admin.users.schema.nameRequired], + email: [es.admin.users.schema.emailInvalid], + }, + }) + }) + + it("localizes mapped use-case errors for update failures", async () => { + mocks.updateUserUseCase.mockResolvedValue({ + success: false, + errors: { + id: [ + "Cannot remove access from the last active administrator", + "You cannot remove your own administrator access", + ], + }, + }) + + const result = await updateUserAction({ + id: "user-1", + username: "admin", + name: "Admin", + email: "admin@example.test", + role: "MANAGER", + isActive: true, + }) + + expect(result).toEqual({ + success: false, + errors: { + id: [ + es.admin.users.actions.lastActiveAdmin, + es.admin.users.actions.selfAdminAccess, + ], + }, + message: es.admin.users.actions.updateFailure, + }) + }) + + it("returns a localized update success message and revalidates", async () => { + mocks.updateUserUseCase.mockResolvedValue({ success: true }) + + const result = await updateUserAction({ + id: "user-1", + username: "admin", + name: "Admin", + email: "admin@example.test", + role: "ADMIN", + isActive: true, + }) + + expect(result).toEqual({ + success: true, + message: es.admin.users.actions.updateSuccess, + }) + expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users") + }) + }) + + describe("setUserActiveAction", () => { + it("returns localized schema validation errors for invalid input", async () => { + const result = await setUserActiveAction({ + id: "", + isActive: false, + }) + + expect(mocks.getI18n).toHaveBeenCalledOnce() + expect(mocks.setUserActiveUseCase).not.toHaveBeenCalled() + expect(result).toEqual({ + success: false, + errors: { + id: [es.admin.users.schema.userIdRequired], + }, + }) + }) + + it("localizes mapped use-case errors for toggle failures", async () => { + mocks.setUserActiveUseCase.mockResolvedValue({ + success: false, + errors: { + id: ["You cannot deactivate your own user"], + }, + }) + + const result = await setUserActiveAction({ + id: "user-1", + isActive: false, + }) + + expect(result).toEqual({ + success: false, + errors: { + id: [es.admin.users.actions.selfDeactivate], + }, + message: es.admin.users.actions.toggleStatusFailure, + }) + }) + + it("returns a localized toggle status success message and revalidates", async () => { + mocks.setUserActiveUseCase.mockResolvedValue({ success: true }) + + const result = await setUserActiveAction({ + id: "user-1", + isActive: false, + }) + + expect(result).toEqual({ + success: true, + message: es.admin.users.actions.toggleStatusSuccess, + }) + expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users") + }) + }) + + describe("resetUserPasswordAction", () => { + it("returns localized schema validation errors for invalid input", async () => { + const result = await resetUserPasswordAction({ + id: "", + password: "short", + }) + + expect(mocks.getI18n).toHaveBeenCalledOnce() + expect(mocks.resetUserPasswordUseCase).not.toHaveBeenCalled() + expect(result).toEqual({ + success: false, + errors: { + id: [es.admin.users.schema.userIdRequired], + password: [es.admin.users.schema.passwordMinLength], + }, + }) + }) + + it("localizes mapped use-case errors for reset failures", async () => { + mocks.resetUserPasswordUseCase.mockResolvedValue({ + success: false, + errors: { id: ["User not found"] }, + }) + + const result = await resetUserPasswordAction({ + id: "nonexistent", + password: "newpassword1", + }) + + expect(result).toEqual({ + success: false, + errors: { + id: [es.admin.users.actions.notFound], + }, + message: es.admin.users.actions.resetPasswordFailure, + }) + }) + + it("returns a localized reset success message and revalidates", async () => { + mocks.resetUserPasswordUseCase.mockResolvedValue({ success: true }) + + const result = await resetUserPasswordAction({ + id: "user-1", + password: "newpassword1", + }) + + expect(result).toEqual({ + success: true, + message: es.admin.users.actions.resetPasswordSuccess, + }) + expect(mocks.revalidatePath).toHaveBeenCalledWith("/admin/users") + }) + }) +}) diff --git a/tests/unit/actions/user.messages.test.ts b/tests/unit/actions/user.messages.test.ts new file mode 100644 index 0000000..b9b9573 --- /dev/null +++ b/tests/unit/actions/user.messages.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest" + +import { localizeUserFieldErrors } from "@/actions/user.messages" +import { es } from "@/i18n/dictionaries/es" + +const actionCopy = es.admin.users.actions + +describe("user action message localization", () => { + it("localizes all 6 known use-case error strings to dictionary keys", () => { + expect( + localizeUserFieldErrors( + { + username: ["Username already exists"], + email: ["Email already exists"], + id: [ + "User not found", + "Cannot remove access from the last active administrator", + "You cannot remove your own administrator access", + "You cannot deactivate your own user", + ], + }, + actionCopy, + ), + ).toEqual({ + username: [actionCopy.duplicateUsername], + email: [actionCopy.duplicateEmail], + id: [ + actionCopy.notFound, + actionCopy.lastActiveAdmin, + actionCopy.selfAdminAccess, + actionCopy.selfDeactivate, + ], + }) + }) + + it("keeps unknown messages unchanged", () => { + expect( + localizeUserFieldErrors({ id: ["Unexpected user issue"] }, actionCopy), + ).toEqual({ id: ["Unexpected user issue"] }) + }) + + it("returns undefined for undefined input", () => { + expect(localizeUserFieldErrors(undefined, actionCopy)).toBeUndefined() + }) +}) diff --git a/tests/unit/i18n/admin-users-dictionary.test.ts b/tests/unit/i18n/admin-users-dictionary.test.ts new file mode 100644 index 0000000..676ff2d --- /dev/null +++ b/tests/unit/i18n/admin-users-dictionary.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from "vitest" + +import { dictionaries, getDictionary } from "@/i18n/dictionaries" + +describe("admin users dictionary", () => { + it("provides localized admin users copy for English", () => { + const users = getDictionary("en").admin.users + + expect(users.list).toEqual({ + title: "Users", + empty: "No users found.", + columns: { + name: "Name", + username: "Username", + email: "Email", + role: "Role", + status: "Status", + actions: "Actions", + }, + actions: { + edit: "Edit user", + }, + }) + + expect(users.new).toEqual({ title: "New User" }) + expect(users.edit).toEqual({ title: "Edit User" }) + + expect(users.form).toEqual({ + nameLabel: "Name", + namePlaceholder: "Full name", + usernameLabel: "Username", + usernamePlaceholder: "Username", + emailLabel: "Email", + emailPlaceholder: "user@example.com", + passwordLabel: "Password", + passwordPlaceholder: "Minimum 8 characters", + roleLabel: "Role", + activeLabel: "Active user", + createSubmit: "Create User", + updateSubmit: "Update User", + }) + + expect(users.resetPassword).toEqual({ + title: "Reset password", + passwordLabel: "New password", + passwordPlaceholder: "Minimum 8 characters", + submit: "Reset Password", + }) + + expect(users.roles).toEqual({ + ADMIN: "Admin", + MANAGER: "Manager", + STAFF: "Staff", + VIEWER: "Viewer", + }) + + expect(users.status).toEqual({ + active: "Active", + inactive: "Inactive", + }) + + expect(users.actions).toEqual({ + createSuccess: "User created successfully", + createFailure: "Failed to create user", + updateSuccess: "User updated successfully", + updateFailure: "Failed to update user", + toggleStatusSuccess: "User status updated successfully", + toggleStatusFailure: "Failed to update user status", + resetPasswordSuccess: "Password reset successfully", + resetPasswordFailure: "Failed to reset password", + duplicateUsername: "Username already exists", + duplicateEmail: "Email already exists", + notFound: "User not found", + lastActiveAdmin: + "Cannot remove access from the last active administrator", + selfAdminAccess: "You cannot remove your own administrator access", + selfDeactivate: "You cannot deactivate your own user", + }) + + expect(users.schema).toEqual({ + usernameRequired: "Username is required", + nameRequired: "Name is required", + emailInvalid: "Invalid email", + passwordMinLength: "Password must be at least 8 characters", + userIdRequired: "User id is required", + }) + + expect(users.fallback).toEqual({ + unknownRole: "Unknown role", + }) + }) + + it("provides localized admin users copy for Spanish", () => { + const users = getDictionary("es").admin.users + + expect(users.list).toEqual({ + title: "Usuarios", + empty: "No se encontraron usuarios.", + columns: { + name: "Nombre", + username: "Usuario", + email: "Correo electrónico", + role: "Rol", + status: "Estado", + actions: "Acciones", + }, + actions: { + edit: "Editar usuario", + }, + }) + + expect(users.new).toEqual({ title: "Nuevo usuario" }) + expect(users.edit).toEqual({ title: "Editar usuario" }) + + expect(users.form).toEqual({ + nameLabel: "Nombre", + namePlaceholder: "Nombre completo", + usernameLabel: "Usuario", + usernamePlaceholder: "Usuario", + emailLabel: "Correo electrónico", + emailPlaceholder: "usuario@ejemplo.com", + passwordLabel: "Contraseña", + passwordPlaceholder: "Mínimo 8 caracteres", + roleLabel: "Rol", + activeLabel: "Usuario activo", + createSubmit: "Crear usuario", + updateSubmit: "Actualizar usuario", + }) + + expect(users.resetPassword).toEqual({ + title: "Restablecer contraseña", + passwordLabel: "Nueva contraseña", + passwordPlaceholder: "Mínimo 8 caracteres", + submit: "Restablecer contraseña", + }) + + expect(users.roles).toEqual({ + ADMIN: "Administrador", + MANAGER: "Gerente", + STAFF: "Personal", + VIEWER: "Visor", + }) + + expect(users.status).toEqual({ + active: "Activo", + inactive: "Inactivo", + }) + + expect(users.actions).toEqual({ + createSuccess: "Usuario creado correctamente", + createFailure: "Error al crear el usuario", + updateSuccess: "Usuario actualizado correctamente", + updateFailure: "Error al actualizar el usuario", + toggleStatusSuccess: "Estado del usuario actualizado correctamente", + toggleStatusFailure: "Error al actualizar el estado del usuario", + resetPasswordSuccess: "Contraseña restablecida correctamente", + resetPasswordFailure: "Error al restablecer la contraseña", + duplicateUsername: "El nombre de usuario ya existe", + duplicateEmail: "El correo electrónico ya existe", + notFound: "Usuario no encontrado", + lastActiveAdmin: + "No se puede eliminar el acceso del último administrador activo", + selfAdminAccess: "No puedes eliminar tu propio acceso de administrador", + selfDeactivate: "No puedes desactivar tu propio usuario", + }) + + expect(users.schema).toEqual({ + usernameRequired: "El usuario es obligatorio", + nameRequired: "El nombre es obligatorio", + emailInvalid: "Correo electrónico no válido", + passwordMinLength: "La contraseña debe tener al menos 8 caracteres", + userIdRequired: "El ID de usuario es obligatorio", + }) + + expect(users.fallback).toEqual({ + unknownRole: "Rol desconocido", + }) + }) + + it("maintains structural parity between English and Spanish admin.users", () => { + const enKeys = extractKeyPaths(dictionaries.en.admin.users) + const esKeys = extractKeyPaths(dictionaries.es.admin.users) + + expect(esKeys).toEqual(enKeys) + }) +}) + +function extractKeyPaths(value: unknown, prefix = ""): string[] { + if (!isPlainObject(value)) return [prefix] + + return Object.keys(value) + .sort() + .flatMap((key) => + extractKeyPaths(value[key], prefix ? `${prefix}.${key}` : key), + ) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/tests/unit/schemas/user.schema.test.ts b/tests/unit/schemas/user.schema.test.ts new file mode 100644 index 0000000..d9958e5 --- /dev/null +++ b/tests/unit/schemas/user.schema.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest" + +import { + buildCreateUserSchema, + buildResetUserPasswordSchema, + buildSetUserActiveSchema, + buildUpdateUserSchema, + createUserSchema, + defaultUserSchemaCopy, + type UserSchemaCopy, + updateUserSchema, +} from "@/schemas/user.schema" + +const esCopy: UserSchemaCopy = { + usernameRequired: "El usuario es obligatorio", + nameRequired: "El nombre es obligatorio", + emailInvalid: "Correo electrónico no válido", + passwordMinLength: "La contraseña debe tener al menos 8 caracteres", + userIdRequired: "El ID de usuario es obligatorio", +} + +const validCreateData = { + username: "admin", + name: "Admin User", + email: "admin@example.test", + password: "password1", + role: "ADMIN" as const, + isActive: true, +} + +const validUpdateData = { + id: "user-id", + username: "admin", + name: "Admin User", + email: "admin@example.test", + role: "ADMIN" as const, + isActive: true, +} + +describe("user schema localization", () => { + it("buildCreateUserSchema with default copy produces same validation as createUserSchema", () => { + const result = buildCreateUserSchema(defaultUserSchemaCopy).safeParse({ + username: "", + name: "", + email: "bad", + password: "short", + role: "INVALID", + isActive: true, + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + expect(errors.username).toContain("Username is required") + expect(errors.name).toContain("Name is required") + expect(errors.email).toContain("Invalid email") + expect(errors.password).toContain( + "Password must be at least 8 characters", + ) + // role error comes from z.enum, not our copy + expect(errors.role).toBeDefined() + } + }) + + it("buildCreateUserSchema with Spanish copy produces Spanish error messages", () => { + const result = buildCreateUserSchema(esCopy).safeParse({ + username: "", + name: "", + email: "bad", + password: "short", + role: "INVALID", + isActive: true, + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + expect(errors.username).toContain(esCopy.usernameRequired) + expect(errors.name).toContain(esCopy.nameRequired) + expect(errors.email).toContain(esCopy.emailInvalid) + expect(errors.password).toContain(esCopy.passwordMinLength) + } + }) + + it("buildUpdateUserSchema with default copy produces same validation as updateUserSchema", () => { + const result = buildUpdateUserSchema(defaultUserSchemaCopy).safeParse({ + id: "", + username: "", + name: "", + email: "bad", + role: "INVALID", + isActive: true, + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + expect(errors.id).toContain("User id is required") + expect(errors.username).toContain("Username is required") + expect(errors.name).toContain("Name is required") + expect(errors.email).toContain("Invalid email") + } + }) + + it("buildUpdateUserSchema with Spanish copy produces Spanish error messages", () => { + const result = buildUpdateUserSchema(esCopy).safeParse({ + id: "", + username: "", + name: "", + email: "bad", + role: "INVALID", + isActive: true, + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + expect(errors.id).toContain(esCopy.userIdRequired) + expect(errors.username).toContain(esCopy.usernameRequired) + expect(errors.name).toContain(esCopy.nameRequired) + expect(errors.email).toContain(esCopy.emailInvalid) + } + }) + + it("buildSetUserActiveSchema with default copy validates id requirement", () => { + const result = buildSetUserActiveSchema(defaultUserSchemaCopy).safeParse({ + id: "", + isActive: true, + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.flatten().fieldErrors.id).toContain( + "User id is required", + ) + } + }) + + it("buildResetUserPasswordSchema with default copy validates password and id", () => { + const result = buildResetUserPasswordSchema( + defaultUserSchemaCopy, + ).safeParse({ + id: "", + password: "short", + }) + + expect(result.success).toBe(false) + if (!result.success) { + const errors = result.error.flatten().fieldErrors + expect(errors.id).toContain("User id is required") + expect(errors.password).toContain( + "Password must be at least 8 characters", + ) + } + }) + + it("default exports match builder with default copy for valid data", () => { + expect(createUserSchema.safeParse(validCreateData).success).toBe(true) + expect( + buildCreateUserSchema(defaultUserSchemaCopy).safeParse(validCreateData) + .success, + ).toBe(true) + + expect(updateUserSchema.safeParse(validUpdateData).success).toBe(true) + expect( + buildUpdateUserSchema(defaultUserSchemaCopy).safeParse(validUpdateData) + .success, + ).toBe(true) + }) +})