feat: add unified Person+User creation backend
This commit is contained in:
@@ -9,12 +9,20 @@ import {
|
||||
type CreatePersonFormType,
|
||||
type UpdatePersonFormType,
|
||||
} from "@/schemas/person.schema"
|
||||
import {
|
||||
buildUnifiedCreateSchema,
|
||||
type UnifiedCreateFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import {
|
||||
createPersonUseCase,
|
||||
createPersonUserUseCase,
|
||||
updatePersonUseCase,
|
||||
} from "@/use-cases/person.use-cases"
|
||||
|
||||
import { localizePersonFieldErrors } from "./person.messages"
|
||||
import { localizeUnifiedCreateFieldErrors } from "./user.messages"
|
||||
|
||||
const PERSON_USER_PATH = "/admin/users"
|
||||
|
||||
export async function createNewPerson(formData: CreatePersonFormType) {
|
||||
const { dictionary } = await getI18n()
|
||||
@@ -56,6 +64,50 @@ export async function createNewPerson(formData: CreatePersonFormType) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPersonUserAction(
|
||||
formData: UnifiedCreateFormType,
|
||||
) {
|
||||
const { dictionary } = await getI18n()
|
||||
const userCopy = dictionary.admin.users
|
||||
const schemaCopy = {
|
||||
...userCopy.schema,
|
||||
...dictionary.inventory.people.schema,
|
||||
}
|
||||
const validatedFields = buildUnifiedCreateSchema(schemaCopy).safeParse(
|
||||
formData,
|
||||
)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createPersonUserUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeUnifiedCreateFieldErrors(
|
||||
result.errors,
|
||||
userCopy.actions,
|
||||
schemaCopy,
|
||||
),
|
||||
message: userCopy.actions.createFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(PERSON_USER_PATH)
|
||||
|
||||
return { success: true, message: userCopy.actions.createSuccess }
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return { success: false, message: userCopy.actions.createFailure }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePerson(formData: UpdatePersonFormType) {
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.people
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
import type { UnifiedSchemaCopy } from "@/schemas/user.schema"
|
||||
|
||||
type UserActionCopy = Dictionary["admin"]["users"]["actions"]
|
||||
|
||||
@@ -37,3 +38,66 @@ export function localizeUserFieldErrors(
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
type UnifiedCreateActionCopy = Dictionary["admin"]["users"]["actions"]
|
||||
|
||||
const unifiedCreateErrorMessageKeys = {
|
||||
"Email already exists": "duplicateEmail",
|
||||
} as const satisfies Record<string, keyof UnifiedCreateActionCopy>
|
||||
|
||||
function isUnifiedCreateErrorMessage(
|
||||
message: string,
|
||||
): message is keyof typeof unifiedCreateErrorMessageKeys {
|
||||
return message in unifiedCreateErrorMessageKeys
|
||||
}
|
||||
|
||||
function localizeUnifiedCreateMessage(
|
||||
message: string,
|
||||
copy: UnifiedCreateActionCopy,
|
||||
): string {
|
||||
if (!isUnifiedCreateErrorMessage(message)) return message
|
||||
|
||||
return copy[unifiedCreateErrorMessageKeys[message]]
|
||||
}
|
||||
|
||||
export function localizeUnifiedCreateFieldErrors(
|
||||
errors: FieldErrors | undefined,
|
||||
copy: UnifiedCreateActionCopy,
|
||||
schemaCopy: UnifiedSchemaCopy,
|
||||
): FieldErrors | undefined {
|
||||
if (!errors) return undefined
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(errors).map(([field, messages]) => [
|
||||
field,
|
||||
messages.map((message) => {
|
||||
// Schema-level validation messages come from schemaCopy
|
||||
if (
|
||||
field === "firstName" &&
|
||||
message === schemaCopy.firstNameRequired
|
||||
)
|
||||
return message
|
||||
if (
|
||||
field === "lastName" &&
|
||||
message === schemaCopy.lastNameRequired
|
||||
)
|
||||
return message
|
||||
if (
|
||||
field === "department" &&
|
||||
message === schemaCopy.departmentRequired
|
||||
)
|
||||
return message
|
||||
if (field === "email" && message === schemaCopy.emailInvalid)
|
||||
return message
|
||||
if (
|
||||
field === "password" &&
|
||||
message === schemaCopy.passwordMinLength
|
||||
)
|
||||
return message
|
||||
|
||||
// Action-level messages (like "Email already exists") come from action copy
|
||||
return localizeUnifiedCreateMessage(message, copy)
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -473,6 +473,7 @@ export const en = {
|
||||
MANAGER: "Manager",
|
||||
STAFF: "Staff",
|
||||
VIEWER: "Viewer",
|
||||
NO_USER: "No user account",
|
||||
},
|
||||
status: {
|
||||
active: "Active",
|
||||
|
||||
@@ -478,6 +478,7 @@ export const es = {
|
||||
MANAGER: "Gerente",
|
||||
STAFF: "Personal",
|
||||
VIEWER: "Visor",
|
||||
NO_USER: "Sin cuenta de usuario",
|
||||
},
|
||||
status: {
|
||||
active: "Activo",
|
||||
|
||||
@@ -13,7 +13,7 @@ const defaultPersonSchemaCopy: PersonSchemaCopy = {
|
||||
userIdInvalid: "User ID must be a valid UUID",
|
||||
}
|
||||
|
||||
const personDepartments = [
|
||||
export const personDepartments = [
|
||||
"IT",
|
||||
"ENGINEERING",
|
||||
"TRAFFIC",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
import { personDepartments } from "@/schemas/person.schema"
|
||||
|
||||
export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
|
||||
|
||||
export type UnifiedSchemaCopy = Dictionary["admin"]["users"]["schema"] &
|
||||
Dictionary["inventory"]["people"]["schema"]
|
||||
|
||||
export const defaultUserSchemaCopy: UserSchemaCopy = {
|
||||
nameRequired: "Name is required",
|
||||
emailInvalid: "Invalid email",
|
||||
@@ -67,3 +71,43 @@ export type CreateUserFormType = z.infer<typeof createUserSchema>
|
||||
export type UpdateUserFormType = z.infer<typeof updateUserSchema>
|
||||
export type SetUserActiveFormType = z.infer<typeof setUserActiveSchema>
|
||||
export type ResetUserPasswordFormType = z.infer<typeof resetUserPasswordSchema>
|
||||
|
||||
export const unifiedFormRoleSchema = z.enum([
|
||||
"ADMIN",
|
||||
"MANAGER",
|
||||
"STAFF",
|
||||
"VIEWER",
|
||||
"NO_USER",
|
||||
])
|
||||
|
||||
export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
|
||||
return z
|
||||
.object({
|
||||
firstName: z.string().trim().min(1, { error: copy.firstNameRequired }),
|
||||
lastName: z.string().trim().min(1, { error: copy.lastNameRequired }),
|
||||
department: z.enum(personDepartments, {
|
||||
error: copy.departmentRequired,
|
||||
}),
|
||||
email: z.email({ error: copy.emailInvalid }),
|
||||
phone: z.string().optional().nullable(),
|
||||
role: unifiedFormRoleSchema,
|
||||
password: z.string().optional(),
|
||||
isActive: z.boolean(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (
|
||||
data.role !== "NO_USER" &&
|
||||
(!data.password || data.password.length < 8)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: copy.passwordMinLength,
|
||||
path: ["password"],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type UnifiedCreateFormType = z.infer<
|
||||
ReturnType<typeof buildUnifiedCreateSchema>
|
||||
>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Prisma } from "@/generated/prisma/client"
|
||||
import prisma from "@/lib/prisma"
|
||||
import { getPasswordHash } from "@/lib/security"
|
||||
import type {
|
||||
CreatePersonFormType,
|
||||
UpdatePersonFormType,
|
||||
} from "@/schemas/person.schema"
|
||||
import type { UnifiedCreateFormType } from "@/schemas/user.schema"
|
||||
import { getUserByEmail } from "@/services/user.service"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
@@ -127,3 +130,82 @@ export async function updatePersonUseCase(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPersonUserUseCase(
|
||||
input: UnifiedCreateFormType,
|
||||
): Promise<PersonUseCaseResult> {
|
||||
const { firstName, lastName, department, email, phone, role, password, isActive } =
|
||||
input
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Cross-table email uniqueness: check both Person and User tables
|
||||
const existingPersonEmail = await PersonService.findByEmail(email, tx)
|
||||
if (existingPersonEmail) {
|
||||
return personError({ email: ["Email already exists"] })
|
||||
}
|
||||
|
||||
const existingUserEmail = await getUserByEmail(email, tx)
|
||||
if (existingUserEmail) {
|
||||
return personError({ email: ["Email already exists"] })
|
||||
}
|
||||
|
||||
if (role === "NO_USER") {
|
||||
// Person-only creation — no User record
|
||||
await PersonService.create(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
email,
|
||||
phone: phone ?? null,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// Person + User creation
|
||||
const person = await PersonService.create(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
email,
|
||||
phone: phone ?? null,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
const userName = `${firstName} ${lastName}`
|
||||
const hashedPassword = await getPasswordHash(password!)
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
name: userName,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role,
|
||||
isActive,
|
||||
},
|
||||
})
|
||||
|
||||
await PersonService.update(
|
||||
person.id,
|
||||
{ user: { connect: { id: user.id } } },
|
||||
tx,
|
||||
)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
const errors = uniqueErrorFor(error)
|
||||
|
||||
if (errors) {
|
||||
return personError(errors)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import type { PrismaClient } from "@/generated/prisma/client"
|
||||
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
stopIntegrationTestDatabase,
|
||||
} from "../helpers/test-db"
|
||||
|
||||
let prisma: PrismaClient
|
||||
let createPersonUserUseCase: typeof import("@/use-cases/person.use-cases").createPersonUserUseCase
|
||||
|
||||
beforeAll(async () => {
|
||||
await startIntegrationTestDatabase()
|
||||
|
||||
const prismaModule = await import("@/lib/prisma")
|
||||
const personUseCases = await import("@/use-cases/person.use-cases")
|
||||
|
||||
prisma = prismaModule.prisma
|
||||
createPersonUserUseCase = personUseCases.createPersonUserUseCase
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetIntegrationTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma?.$disconnect()
|
||||
await stopIntegrationTestDatabase()
|
||||
})
|
||||
|
||||
describe("createPersonUserUseCase", () => {
|
||||
describe("NO_USER role (person-only creation)", () => {
|
||||
it("creates a Person without a User record when role is NO_USER", async () => {
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const person = await prisma.person.findFirstOrThrow({
|
||||
where: { firstName: "John", lastName: "Doe" },
|
||||
})
|
||||
expect(person).toMatchObject({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
userId: null,
|
||||
})
|
||||
|
||||
// No User record created
|
||||
await expect(
|
||||
prisma.user.findUnique({ where: { email: "john@example.test" } }),
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it("creates a Person with null email when not providing email and role is NO_USER", async () => {
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
department: "ENGINEERING",
|
||||
email: "jane-noemail@example.test",
|
||||
phone: "555-1234",
|
||||
role: "NO_USER",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const person = await prisma.person.findFirstOrThrow({
|
||||
where: { firstName: "Jane", lastName: "Smith" },
|
||||
})
|
||||
expect(person.phone).toBe("555-1234")
|
||||
})
|
||||
})
|
||||
|
||||
describe("real role (person + user creation)", () => {
|
||||
it("creates Person and User with linked userId when role is ADMIN", async () => {
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Admin",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
email: "admin@example.test",
|
||||
phone: null,
|
||||
role: "ADMIN",
|
||||
password: "secure-password",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const person = await prisma.person.findFirstOrThrow({
|
||||
where: { firstName: "Admin", lastName: "User" },
|
||||
})
|
||||
expect(person).toMatchObject({
|
||||
firstName: "Admin",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
email: "admin@example.test",
|
||||
})
|
||||
|
||||
// User record should exist with derived name
|
||||
expect(person.userId).not.toBeNull()
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: person.userId! },
|
||||
})
|
||||
expect(user).toMatchObject({
|
||||
name: "Admin User",
|
||||
email: "admin@example.test",
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => {
|
||||
const roles = ["MANAGER", "STAFF", "VIEWER"] as const
|
||||
|
||||
for (const role of roles) {
|
||||
const suffix = role.toLowerCase()
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Person",
|
||||
lastName: suffix,
|
||||
department: "IT",
|
||||
email: `${suffix}@example.test`,
|
||||
phone: null,
|
||||
role,
|
||||
password: "secure-password",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const person = await prisma.person.findFirstOrThrow({
|
||||
where: { lastName: suffix },
|
||||
})
|
||||
expect(person.userId).not.toBeNull()
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: person.userId! },
|
||||
})
|
||||
expect(user.role).toBe(role)
|
||||
expect(user.name).toBe(`Person ${suffix}`)
|
||||
}
|
||||
})
|
||||
|
||||
it("derives User.name from firstName + lastName", async () => {
|
||||
await createPersonUserUseCase({
|
||||
firstName: "Maria",
|
||||
lastName: "Garcia",
|
||||
department: "SALES",
|
||||
email: "maria@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
password: "secure-password",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { email: "maria@example.test" },
|
||||
})
|
||||
expect(user.name).toBe("Maria Garcia")
|
||||
})
|
||||
|
||||
it("hashes the password when creating a User", async () => {
|
||||
await createPersonUserUseCase({
|
||||
firstName: "Hash",
|
||||
lastName: "Test",
|
||||
department: "IT",
|
||||
email: "hash-test@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
password: "plaintext-password",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { email: "hash-test@example.test" },
|
||||
})
|
||||
expect(user.password).not.toBe("plaintext-password")
|
||||
|
||||
const { verifyPassword } = await import("@/lib/security")
|
||||
await expect(
|
||||
verifyPassword("plaintext-password", user.password),
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cross-table email uniqueness", () => {
|
||||
it("rejects submission when email already exists in Person table", async () => {
|
||||
await createTestPerson(prisma, { email: "existing-person@example.test" })
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Duplicate",
|
||||
lastName: "Person",
|
||||
department: "IT",
|
||||
email: "existing-person@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: { email: ["Email already exists"] },
|
||||
})
|
||||
|
||||
await expect(prisma.person.count()).resolves.toBe(1)
|
||||
})
|
||||
|
||||
it("rejects submission when email already exists in User table", async () => {
|
||||
await createTestUser(prisma, { email: "existing-user@example.test" })
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Duplicate",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
email: "existing-user@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
password: "secure-password",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: { email: ["Email already exists"] },
|
||||
})
|
||||
|
||||
// No new Person or User was created
|
||||
await expect(prisma.person.count()).resolves.toBe(0)
|
||||
await expect(prisma.user.count()).resolves.toBe(1)
|
||||
})
|
||||
|
||||
it("accepts submission when email is unique across both tables", async () => {
|
||||
// Create a Person and a User with different emails
|
||||
await createTestPerson(prisma, { email: "person@example.test" })
|
||||
await createTestUser(prisma, { email: "user@example.test" })
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "New",
|
||||
lastName: "Person",
|
||||
department: "IT",
|
||||
email: "new@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ describe("user copy helpers", () => {
|
||||
MANAGER: "Gerente",
|
||||
STAFF: "Personal",
|
||||
VIEWER: "Visor",
|
||||
NO_USER: "Sin cuenta de usuario",
|
||||
}
|
||||
|
||||
const fallbackCopy = {
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("admin users dictionary", () => {
|
||||
MANAGER: "Manager",
|
||||
STAFF: "Staff",
|
||||
VIEWER: "Viewer",
|
||||
NO_USER: "No user account",
|
||||
})
|
||||
|
||||
expect(users.status).toEqual({
|
||||
@@ -133,6 +134,7 @@ describe("admin users dictionary", () => {
|
||||
MANAGER: "Gerente",
|
||||
STAFF: "Personal",
|
||||
VIEWER: "Visor",
|
||||
NO_USER: "Sin cuenta de usuario",
|
||||
})
|
||||
|
||||
expect(users.status).toEqual({
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
buildUnifiedCreateSchema,
|
||||
unifiedFormRoleSchema,
|
||||
type UnifiedSchemaCopy,
|
||||
} from "@/schemas/user.schema"
|
||||
|
||||
const enCopy: UnifiedSchemaCopy = {
|
||||
firstNameRequired: "First name is required",
|
||||
lastNameRequired: "Last name is required",
|
||||
departmentRequired: "Department is required",
|
||||
emailInvalid: "Invalid email",
|
||||
passwordMinLength: "Password must be at least 8 characters",
|
||||
nameRequired: "Name is required",
|
||||
userIdRequired: "User id is required",
|
||||
idRequired: "ID is required",
|
||||
userIdInvalid: "User ID must be a valid UUID",
|
||||
}
|
||||
|
||||
const esCopy: UnifiedSchemaCopy = {
|
||||
firstNameRequired: "El nombre es obligatorio",
|
||||
lastNameRequired: "El apellido es obligatorio",
|
||||
departmentRequired: "El departamento es obligatorio",
|
||||
emailInvalid: "Correo electrónico no válido",
|
||||
passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
|
||||
nameRequired: "El nombre es obligatorio",
|
||||
userIdRequired: "El ID de usuario es obligatorio",
|
||||
idRequired: "El ID es obligatorio",
|
||||
userIdInvalid: "El ID de usuario debe ser un UUID válido",
|
||||
}
|
||||
|
||||
const validPersonOnlyData = {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER" as const,
|
||||
password: undefined,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
const validPersonWithUserData = {
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
department: "ENGINEERING",
|
||||
email: "jane@example.test",
|
||||
phone: "1234567890",
|
||||
role: "ADMIN" as const,
|
||||
password: "securepassword",
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
describe("unifiedFormRoleSchema", () => {
|
||||
it("accepts all standard roles plus NO_USER", () => {
|
||||
const roles = ["ADMIN", "MANAGER", "STAFF", "VIEWER", "NO_USER"]
|
||||
for (const role of roles) {
|
||||
expect(unifiedFormRoleSchema.safeParse(role).success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects invalid roles", () => {
|
||||
const result = unifiedFormRoleSchema.safeParse("SUPER_ADMIN")
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildUnifiedCreateSchema", () => {
|
||||
describe("with NO_USER role (person-only creation)", () => {
|
||||
it("accepts valid data without password when role is NO_USER", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse(validPersonOnlyData)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.firstName).toBe("John")
|
||||
expect(result.data.lastName).toBe("Doe")
|
||||
expect(result.data.role).toBe("NO_USER")
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts valid data with empty string password when role is NO_USER", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
password: "",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("uses localized error messages for required fields", () => {
|
||||
const schema = buildUnifiedCreateSchema(esCopy)
|
||||
const result = schema.safeParse({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
department: "",
|
||||
email: "not-an-email",
|
||||
role: "NO_USER",
|
||||
phone: null,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
expect(errors.firstName).toContain(esCopy.firstNameRequired)
|
||||
expect(errors.lastName).toContain(esCopy.lastNameRequired)
|
||||
expect(errors.department).toContain(esCopy.departmentRequired)
|
||||
expect(errors.email).toContain(esCopy.emailInvalid)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("with a real User role (person + user creation)", () => {
|
||||
it("accepts valid data with password when role is ADMIN", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse(validPersonWithUserData)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.firstName).toBe("Jane")
|
||||
expect(result.data.role).toBe("ADMIN")
|
||||
expect(result.data.password).toBe("securepassword")
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts valid data with password for all real roles", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
for (const role of ["ADMIN", "MANAGER", "STAFF", "VIEWER"] as const) {
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUserData,
|
||||
role,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects short password when role is not NO_USER", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUserData,
|
||||
password: "short",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
expect(errors.password).toContain(enCopy.passwordMinLength)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects missing password when role is not NO_USER", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUserData,
|
||||
password: undefined,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
expect(errors.password).toContain(enCopy.passwordMinLength)
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects empty string password when role is not NO_USER", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUserData,
|
||||
password: "",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
expect(errors.password).toContain(enCopy.passwordMinLength)
|
||||
}
|
||||
})
|
||||
|
||||
it("uses localized password error message", () => {
|
||||
const schema = buildUnifiedCreateSchema(esCopy)
|
||||
const result = schema.safeParse({
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
department: "ENGINEERING",
|
||||
email: "jane@example.test",
|
||||
role: "ADMIN",
|
||||
password: "corta",
|
||||
phone: null,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors
|
||||
expect(errors.password).toContain(esCopy.passwordMinLength)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("email validation", () => {
|
||||
it("rejects invalid email format", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
email: "not-an-email",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.email).toContain(
|
||||
enCopy.emailInvalid,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts valid email", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
email: "valid@example.com",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("department validation", () => {
|
||||
it("rejects invalid department", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
department: "INVALID_DEPT",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("accepts valid departments", () => {
|
||||
const schema = buildUnifiedCreateSchema(enCopy)
|
||||
const validDepartments = [
|
||||
"IT",
|
||||
"ENGINEERING",
|
||||
"TRAFFIC",
|
||||
"DRIVER",
|
||||
"LOGISTICS",
|
||||
"ADMINISTRATION",
|
||||
"SALES",
|
||||
"OTHER",
|
||||
]
|
||||
for (const dept of validDepartments) {
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnlyData,
|
||||
department: dept,
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user