feat: add unified Person+User creation backend
This commit is contained in:
@@ -9,12 +9,20 @@ import {
|
|||||||
type CreatePersonFormType,
|
type CreatePersonFormType,
|
||||||
type UpdatePersonFormType,
|
type UpdatePersonFormType,
|
||||||
} from "@/schemas/person.schema"
|
} from "@/schemas/person.schema"
|
||||||
|
import {
|
||||||
|
buildUnifiedCreateSchema,
|
||||||
|
type UnifiedCreateFormType,
|
||||||
|
} from "@/schemas/user.schema"
|
||||||
import {
|
import {
|
||||||
createPersonUseCase,
|
createPersonUseCase,
|
||||||
|
createPersonUserUseCase,
|
||||||
updatePersonUseCase,
|
updatePersonUseCase,
|
||||||
} from "@/use-cases/person.use-cases"
|
} from "@/use-cases/person.use-cases"
|
||||||
|
|
||||||
import { localizePersonFieldErrors } from "./person.messages"
|
import { localizePersonFieldErrors } from "./person.messages"
|
||||||
|
import { localizeUnifiedCreateFieldErrors } from "./user.messages"
|
||||||
|
|
||||||
|
const PERSON_USER_PATH = "/admin/users"
|
||||||
|
|
||||||
export async function createNewPerson(formData: CreatePersonFormType) {
|
export async function createNewPerson(formData: CreatePersonFormType) {
|
||||||
const { dictionary } = await getI18n()
|
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) {
|
export async function updatePerson(formData: UpdatePersonFormType) {
|
||||||
const { dictionary } = await getI18n()
|
const { dictionary } = await getI18n()
|
||||||
const copy = dictionary.inventory.people
|
const copy = dictionary.inventory.people
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Dictionary } from "@/i18n/dictionaries"
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
|
import type { UnifiedSchemaCopy } from "@/schemas/user.schema"
|
||||||
|
|
||||||
type UserActionCopy = Dictionary["admin"]["users"]["actions"]
|
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",
|
MANAGER: "Manager",
|
||||||
STAFF: "Staff",
|
STAFF: "Staff",
|
||||||
VIEWER: "Viewer",
|
VIEWER: "Viewer",
|
||||||
|
NO_USER: "No user account",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
active: "Active",
|
active: "Active",
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ export const es = {
|
|||||||
MANAGER: "Gerente",
|
MANAGER: "Gerente",
|
||||||
STAFF: "Personal",
|
STAFF: "Personal",
|
||||||
VIEWER: "Visor",
|
VIEWER: "Visor",
|
||||||
|
NO_USER: "Sin cuenta de usuario",
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
active: "Activo",
|
active: "Activo",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const defaultPersonSchemaCopy: PersonSchemaCopy = {
|
|||||||
userIdInvalid: "User ID must be a valid UUID",
|
userIdInvalid: "User ID must be a valid UUID",
|
||||||
}
|
}
|
||||||
|
|
||||||
const personDepartments = [
|
export const personDepartments = [
|
||||||
"IT",
|
"IT",
|
||||||
"ENGINEERING",
|
"ENGINEERING",
|
||||||
"TRAFFIC",
|
"TRAFFIC",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import type { Dictionary } from "@/i18n/dictionaries"
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
|
import { personDepartments } from "@/schemas/person.schema"
|
||||||
|
|
||||||
export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
|
export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
|
||||||
|
|
||||||
|
export type UnifiedSchemaCopy = Dictionary["admin"]["users"]["schema"] &
|
||||||
|
Dictionary["inventory"]["people"]["schema"]
|
||||||
|
|
||||||
export const defaultUserSchemaCopy: UserSchemaCopy = {
|
export const defaultUserSchemaCopy: UserSchemaCopy = {
|
||||||
nameRequired: "Name is required",
|
nameRequired: "Name is required",
|
||||||
emailInvalid: "Invalid email",
|
emailInvalid: "Invalid email",
|
||||||
@@ -67,3 +71,43 @@ export type CreateUserFormType = z.infer<typeof createUserSchema>
|
|||||||
export type UpdateUserFormType = z.infer<typeof updateUserSchema>
|
export type UpdateUserFormType = z.infer<typeof updateUserSchema>
|
||||||
export type SetUserActiveFormType = z.infer<typeof setUserActiveSchema>
|
export type SetUserActiveFormType = z.infer<typeof setUserActiveSchema>
|
||||||
export type ResetUserPasswordFormType = z.infer<typeof resetUserPasswordSchema>
|
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 "@/generated/prisma/client"
|
||||||
import prisma from "@/lib/prisma"
|
import prisma from "@/lib/prisma"
|
||||||
|
import { getPasswordHash } from "@/lib/security"
|
||||||
import type {
|
import type {
|
||||||
CreatePersonFormType,
|
CreatePersonFormType,
|
||||||
UpdatePersonFormType,
|
UpdatePersonFormType,
|
||||||
} from "@/schemas/person.schema"
|
} from "@/schemas/person.schema"
|
||||||
|
import type { UnifiedCreateFormType } from "@/schemas/user.schema"
|
||||||
|
import { getUserByEmail } from "@/services/user.service"
|
||||||
import { PersonService } from "@/services/person.service"
|
import { PersonService } from "@/services/person.service"
|
||||||
|
|
||||||
type FieldErrors = Record<string, string[]>
|
type FieldErrors = Record<string, string[]>
|
||||||
@@ -127,3 +130,82 @@ export async function updatePersonUseCase(
|
|||||||
throw error
|
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",
|
MANAGER: "Gerente",
|
||||||
STAFF: "Personal",
|
STAFF: "Personal",
|
||||||
VIEWER: "Visor",
|
VIEWER: "Visor",
|
||||||
|
NO_USER: "Sin cuenta de usuario",
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackCopy = {
|
const fallbackCopy = {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe("admin users dictionary", () => {
|
|||||||
MANAGER: "Manager",
|
MANAGER: "Manager",
|
||||||
STAFF: "Staff",
|
STAFF: "Staff",
|
||||||
VIEWER: "Viewer",
|
VIEWER: "Viewer",
|
||||||
|
NO_USER: "No user account",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(users.status).toEqual({
|
expect(users.status).toEqual({
|
||||||
@@ -133,6 +134,7 @@ describe("admin users dictionary", () => {
|
|||||||
MANAGER: "Gerente",
|
MANAGER: "Gerente",
|
||||||
STAFF: "Personal",
|
STAFF: "Personal",
|
||||||
VIEWER: "Visor",
|
VIEWER: "Visor",
|
||||||
|
NO_USER: "Sin cuenta de usuario",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(users.status).toEqual({
|
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