refactor: consolidate admin/users management under /people
This commit is contained in:
@@ -11,12 +11,16 @@ import {
|
||||
} from "@/schemas/person.schema"
|
||||
import {
|
||||
buildUnifiedCreateSchema,
|
||||
buildUnifiedUpdateSchema,
|
||||
type UnifiedCreateFormType,
|
||||
type UnifiedSchemaCopy,
|
||||
type UnifiedUpdateFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import {
|
||||
createPersonUseCase,
|
||||
createPersonUserUseCase,
|
||||
updatePersonUseCase,
|
||||
updatePersonUserUseCase,
|
||||
} from "@/use-cases/person.use-cases"
|
||||
|
||||
import { localizePersonFieldErrors } from "./person.messages"
|
||||
@@ -144,3 +148,45 @@ export async function updatePerson(formData: UpdatePersonFormType) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePersonUserAction(formData: UnifiedUpdateFormType) {
|
||||
const { dictionary } = await getI18n()
|
||||
const userCopy = dictionary.admin.users
|
||||
const personCopy = dictionary.inventory.people
|
||||
const schemaCopy: UnifiedSchemaCopy = {
|
||||
...userCopy.schema,
|
||||
...personCopy.schema,
|
||||
}
|
||||
const validatedFields =
|
||||
buildUnifiedUpdateSchema(schemaCopy).safeParse(formData)
|
||||
|
||||
if (!validatedFields.success) {
|
||||
return {
|
||||
success: false,
|
||||
errors: flattenError(validatedFields.error).fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updatePersonUserUseCase(validatedFields.data)
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
errors: localizeUnifiedCreateFieldErrors(
|
||||
result.errors,
|
||||
userCopy.actions,
|
||||
schemaCopy,
|
||||
),
|
||||
message: personCopy.actions.updateFailure,
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/people")
|
||||
|
||||
return { success: true, message: personCopy.actions.updateSuccess }
|
||||
} catch (error) {
|
||||
console.error("Database error:", error)
|
||||
return { success: false, message: personCopy.actions.updateFailure }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { getUserProfileById } from "@/services/user.service"
|
||||
|
||||
import EditUserForm from "../../_components/edit.user.form"
|
||||
import ResetUserPasswordForm from "../../_components/reset.user.password.form"
|
||||
import prisma from "@/lib/prisma"
|
||||
|
||||
export default async function EditUserPage({
|
||||
params,
|
||||
@@ -12,35 +8,15 @@ export default async function EditUserPage({
|
||||
params: Promise<{ userId: string }>
|
||||
}) {
|
||||
const { userId } = await params
|
||||
const user = await getUserProfileById(userId)
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.admin.users
|
||||
|
||||
if (!user) {
|
||||
notFound()
|
||||
const person = await prisma.person.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (!person) {
|
||||
redirect("/people")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
|
||||
</div>
|
||||
<EditUserForm
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
roleLabels={copy.roles}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
user={user}
|
||||
/>
|
||||
<section className="flex flex-col gap-4 border-t pt-6">
|
||||
<h2 className="text-xl font-semibold">{copy.resetPassword.title}</h2>
|
||||
<ResetUserPasswordForm
|
||||
formCopy={copy.resetPassword}
|
||||
schemaCopy={copy.schema}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
userId={user.id}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
redirect(`/people/${person.id}/edit`)
|
||||
}
|
||||
|
||||
@@ -1,111 +1,5 @@
|
||||
import { Pencil } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { getUsers } from "@/services/user.service"
|
||||
|
||||
import {
|
||||
formatUserRole,
|
||||
type UserFallbackCopy,
|
||||
type UserRoleCopy,
|
||||
type UserStatusCopy,
|
||||
} from "./_components/user.copy"
|
||||
|
||||
export default async function UsersPage(props: {
|
||||
searchParams?: Promise<{
|
||||
page?: string
|
||||
search?: string
|
||||
}>
|
||||
}) {
|
||||
const searchParams = await props.searchParams
|
||||
const currentPage = searchParams?.page ? parseInt(searchParams.page, 10) : 1
|
||||
const search = searchParams?.search || ""
|
||||
const { data: users, totalPages } = await getUsers({
|
||||
page: currentPage,
|
||||
pageSize: 10,
|
||||
search,
|
||||
})
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.admin.users
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
title={copy.list.title}
|
||||
addLabel={copy.list.addLabel}
|
||||
link="/admin/users/new"
|
||||
search={search}
|
||||
data={users}
|
||||
/>
|
||||
{users.length === 0 && currentPage === 1 && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{copy.list.empty}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="text-muted-foreground w-full text-left text-sm">
|
||||
<thead className="border-b">
|
||||
<tr>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.name}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.email}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.role}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.status}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b">
|
||||
<td className="p-4">{user.name}</td>
|
||||
<td className="p-4">{user.email}</td>
|
||||
<td className="p-4">
|
||||
{formatUserRole(
|
||||
user.role,
|
||||
copy.roles as UserRoleCopy,
|
||||
copy.fallback as UserFallbackCopy,
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{user.isActive
|
||||
? (copy.status as UserStatusCopy).active
|
||||
: (copy.status as UserStatusCopy).inactive}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Link href={`/admin/users/${user.id}/edit`} passHref>
|
||||
<Button variant="outline" size="icon">
|
||||
<Pencil />
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={6} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
export default function UsersPage() {
|
||||
redirect("/people")
|
||||
}
|
||||
|
||||
@@ -4,22 +4,17 @@ import Navbar from "@/components/layout/navbar"
|
||||
import AppSidebar from "@/components/layout/sidebar"
|
||||
import { SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
export default async function LayoutDashboard({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await auth()
|
||||
const { dictionary } = await getI18n()
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
copy={dictionary.layout.sidebar}
|
||||
userRole={session?.user.role}
|
||||
/>
|
||||
<AppSidebar copy={dictionary.layout.sidebar} />
|
||||
<main className="w-full">
|
||||
<Navbar />
|
||||
<div className="flex-1 p-6">{children}</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import PersonForm from "../../_components/person.form"
|
||||
import EditPersonForm from "../../_components/edit.person.form"
|
||||
|
||||
export default async function PersonEditPage({
|
||||
params,
|
||||
@@ -10,25 +10,27 @@ export default async function PersonEditPage({
|
||||
}) {
|
||||
const { personId } = await params
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.people
|
||||
const person = await PersonService.findById(personId)
|
||||
const personCopy = dictionary.inventory.people
|
||||
const userCopy = dictionary.admin.users
|
||||
const person = await PersonService.findByIdWithUser(personId)
|
||||
|
||||
if (!person) {
|
||||
return <div>{copy.edit.notFound}</div>
|
||||
return <div>{personCopy.edit.notFound}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
|
||||
<h1 className="text-2xl font-bold">{personCopy.edit.title}</h1>
|
||||
</div>
|
||||
<PersonForm
|
||||
initialData={person}
|
||||
mode="edit"
|
||||
formCopy={copy.form}
|
||||
schemaCopy={copy.schema}
|
||||
departmentCopy={copy.departments}
|
||||
fallbackCopy={copy.fallback}
|
||||
<EditPersonForm
|
||||
person={person}
|
||||
formCopy={userCopy.form}
|
||||
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
|
||||
roleLabels={userCopy.roles}
|
||||
userFallbackCopy={userCopy.fallback}
|
||||
departmentCopy={personCopy.departments}
|
||||
fallbackCopy={personCopy.fallback}
|
||||
submitButtonCopy={dictionary.common.submitButton}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,11 @@ import { AssignmentService } from "@/services/assignment.service"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import { formatPersonDepartment } from "../_components/person.copy"
|
||||
import {
|
||||
formatUserRole,
|
||||
type UserFallbackCopy,
|
||||
type UserRoleCopy,
|
||||
} from "../_components/user.copy"
|
||||
|
||||
export default async function PersonInfoPage({
|
||||
params,
|
||||
@@ -14,7 +19,8 @@ export default async function PersonInfoPage({
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.people
|
||||
const assignmentCopy = dictionary.inventory.assignments
|
||||
const person = await PersonService.findById(personId)
|
||||
const userCopy = dictionary.admin.users
|
||||
const person = await PersonService.findByIdWithUser(personId)
|
||||
const assignments = await AssignmentService.findAllByPerson(personId)
|
||||
|
||||
if (!person) {
|
||||
@@ -49,6 +55,37 @@ export default async function PersonInfoPage({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{person.user ? (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">
|
||||
{copy.detail.labels.role}
|
||||
</span>
|
||||
<span>
|
||||
{formatUserRole(
|
||||
person.user.role,
|
||||
userCopy.roles as UserRoleCopy,
|
||||
userCopy.fallback as UserFallbackCopy,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">
|
||||
{copy.detail.labels.status}
|
||||
</span>
|
||||
<span>
|
||||
{person.user.isActive
|
||||
? userCopy.status.active
|
||||
: userCopy.status.inactive}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-gray-600">{copy.detail.labels.role}</span>
|
||||
<span>{copy.detail.labels.noUser}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import type { UseFormRegisterReturn } from "react-hook-form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { updatePersonUserAction } from "@/actions/person.actions"
|
||||
import {
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import { PERSON_DEPARTMENTS } from "@/lib/constants"
|
||||
import {
|
||||
buildUnifiedUpdateSchema,
|
||||
type UnifiedSchemaCopy,
|
||||
type UnifiedUpdateFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import type { PersonWithUser } from "@/services/person.service"
|
||||
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
formatUserRole,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
type UserFallbackCopy,
|
||||
type UserFormCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./user.copy"
|
||||
|
||||
export default function EditPersonForm({
|
||||
person,
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
roleLabels,
|
||||
userFallbackCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
submitButtonCopy,
|
||||
}: {
|
||||
person: PersonWithUser
|
||||
formCopy: UserFormCopy
|
||||
schemaCopy: UnifiedSchemaCopy
|
||||
roleLabels: UserRoleCopy
|
||||
userFallbackCopy: UserFallbackCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const schema = useMemo(
|
||||
() => buildUnifiedUpdateSchema(schemaCopy),
|
||||
[schemaCopy],
|
||||
)
|
||||
const hasUser = Boolean(person.userId && person.user)
|
||||
const user = person.user
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<UnifiedUpdateFormType>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
department: person.department ?? "OTHER",
|
||||
email: person.email ?? "",
|
||||
phone: person.phone ?? "",
|
||||
...(hasUser && user ? { role: user.role, isActive: user.isActive } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: UnifiedUpdateFormType) => {
|
||||
const response = await updatePersonUserAction(formData)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((message: string) => {
|
||||
setError(fieldName as keyof UnifiedUpdateFormType, {
|
||||
type: "server",
|
||||
message,
|
||||
})
|
||||
toast.error(message)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/people")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<TextInput
|
||||
error={errors.firstName?.message}
|
||||
id="firstName"
|
||||
label={formCopy.firstNameLabel}
|
||||
placeholder={formCopy.firstNamePlaceholder}
|
||||
register={register("firstName")}
|
||||
/>
|
||||
<TextInput
|
||||
error={errors.lastName?.message}
|
||||
id="lastName"
|
||||
label={formCopy.lastNameLabel}
|
||||
placeholder={formCopy.lastNamePlaceholder}
|
||||
register={register("lastName")}
|
||||
/>
|
||||
<DepartmentSelect
|
||||
error={errors.department?.message}
|
||||
formCopy={formCopy}
|
||||
departmentCopy={departmentCopy}
|
||||
fallbackCopy={fallbackCopy}
|
||||
register={register("department")}
|
||||
/>
|
||||
<TextInput
|
||||
error={errors.email?.message}
|
||||
id="email"
|
||||
label={formCopy.emailLabel}
|
||||
placeholder={formCopy.emailPlaceholder}
|
||||
register={register("email")}
|
||||
type="email"
|
||||
/>
|
||||
<TextInput
|
||||
error={errors.phone?.message}
|
||||
id="phone"
|
||||
label={formCopy.phoneLabel}
|
||||
placeholder={formCopy.phonePlaceholder}
|
||||
register={register("phone")}
|
||||
/>
|
||||
|
||||
{hasUser && (
|
||||
<section
|
||||
className="flex flex-col gap-4 border-t pt-4"
|
||||
aria-labelledby="user-account-heading"
|
||||
>
|
||||
<h2 id="user-account-heading" className="text-xl font-semibold">
|
||||
{formCopy.userAccountHeading}
|
||||
</h2>
|
||||
<RoleSelect
|
||||
register={register("role")}
|
||||
roleLabel={formCopy.roleLabel}
|
||||
roleLabels={roleLabels}
|
||||
/>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" {...register("isActive")} />
|
||||
{formCopy.activeLabel}
|
||||
</label>
|
||||
<TextInput
|
||||
error={errors.password?.message}
|
||||
id="password"
|
||||
label={formCopy.newPasswordLabel}
|
||||
placeholder={formCopy.newPasswordPlaceholder}
|
||||
register={register("password")}
|
||||
type="password"
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
{formCopy.updateSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
error,
|
||||
id,
|
||||
label,
|
||||
placeholder,
|
||||
register,
|
||||
type = "text",
|
||||
}: {
|
||||
error?: string
|
||||
id: string
|
||||
label: string
|
||||
placeholder: string
|
||||
register: UseFormRegisterReturn
|
||||
type?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor={id} className="mb-2 block text-lg">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
type={type}
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
{...register}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||
/>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoleSelect({
|
||||
register,
|
||||
roleLabel,
|
||||
roleLabels,
|
||||
}: {
|
||||
register: UseFormRegisterReturn
|
||||
roleLabel: string
|
||||
roleLabels: UserRoleCopy
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="role" className="mb-2 block text-lg">
|
||||
{roleLabel}
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
{...register}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="ADMIN">{roleLabels.ADMIN}</option>
|
||||
<option value="MANAGER">{roleLabels.MANAGER}</option>
|
||||
<option value="STAFF">{roleLabels.STAFF}</option>
|
||||
<option value="VIEWER">{roleLabels.VIEWER}</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DepartmentSelect({
|
||||
error,
|
||||
formCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
register,
|
||||
}: {
|
||||
error?: string
|
||||
formCopy: UserFormCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
register: UseFormRegisterReturn
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="department" className="mb-2 block text-lg">
|
||||
{formCopy.departmentLabel}
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
{...register}
|
||||
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
|
||||
>
|
||||
<option value="">{formCopy.departmentPlaceholder}</option>
|
||||
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
|
||||
<option key={department} value={department}>
|
||||
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Re-export for tests that need to verify the data shape passed to this form.
|
||||
export { formatUserRole }
|
||||
@@ -1,188 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useMemo } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { createNewPerson, updatePerson } from "@/actions/person.actions"
|
||||
import {
|
||||
SubmitButton,
|
||||
type SubmitButtonCopy,
|
||||
} from "@/components/forms/submitButton"
|
||||
import { PERSON_DEPARTMENTS } from "@/lib/constants"
|
||||
import {
|
||||
buildCreatePersonSchema,
|
||||
buildUpdatePersonSchema,
|
||||
type CreatePersonFormType,
|
||||
type PersonSchemaCopy,
|
||||
type UpdatePersonFormType,
|
||||
} from "@/schemas/person.schema"
|
||||
import type { Person } from "@/types"
|
||||
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
type PersonFormCopy,
|
||||
} from "./person.copy"
|
||||
|
||||
interface PersonFormProps {
|
||||
initialData?: Person
|
||||
mode?: "create" | "edit"
|
||||
formCopy: PersonFormCopy
|
||||
schemaCopy: PersonSchemaCopy
|
||||
departmentCopy: PersonDepartmentCopy
|
||||
fallbackCopy: PersonFallbackCopy
|
||||
submitButtonCopy: SubmitButtonCopy
|
||||
}
|
||||
|
||||
export default function PersonForm({
|
||||
initialData,
|
||||
mode = "create",
|
||||
formCopy,
|
||||
schemaCopy,
|
||||
departmentCopy,
|
||||
fallbackCopy,
|
||||
submitButtonCopy,
|
||||
}: PersonFormProps) {
|
||||
const router = useRouter()
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
mode === "create"
|
||||
? buildCreatePersonSchema(schemaCopy)
|
||||
: buildUpdatePersonSchema(schemaCopy),
|
||||
[mode, schemaCopy],
|
||||
)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful },
|
||||
} = useForm<CreatePersonFormType>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
id: initialData?.id || "",
|
||||
firstName: initialData?.firstName || "",
|
||||
lastName: initialData?.lastName || "",
|
||||
department: initialData?.department || "OTHER",
|
||||
email: initialData?.email || "",
|
||||
phone: initialData?.phone || "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (formData: CreatePersonFormType) => {
|
||||
const response =
|
||||
mode === "create"
|
||||
? await createNewPerson(formData)
|
||||
: await updatePerson(formData as UpdatePersonFormType)
|
||||
|
||||
if (response?.errors) {
|
||||
Object.entries(response.errors).forEach(([fieldName, messages]) => {
|
||||
messages.forEach((msg: string) => {
|
||||
setError(fieldName as keyof CreatePersonFormType, {
|
||||
type: "server",
|
||||
message: msg,
|
||||
})
|
||||
toast.error(msg)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (response?.success) {
|
||||
toast.success(response.message)
|
||||
router.push("/people")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<input type="hidden" {...register("id")} />
|
||||
<div>
|
||||
<label htmlFor="firstName" className="mb-2 block text-lg">
|
||||
{formCopy.firstNameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstName"
|
||||
placeholder={formCopy.firstNamePlaceholder}
|
||||
{...register("firstName")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.firstName && (
|
||||
<p className="text-error">{errors.firstName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="lastName" className="mb-2 block text-lg">
|
||||
{formCopy.lastNameLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastName"
|
||||
placeholder={formCopy.lastNamePlaceholder}
|
||||
{...register("lastName")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.lastName && (
|
||||
<p className="text-error">{errors.lastName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="department" className="mb-2 block text-lg">
|
||||
{formCopy.departmentLabel}
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
{...register("department")}
|
||||
className="w-full rounded-lg border px-4 py-2"
|
||||
>
|
||||
<option value="">{formCopy.departmentPlaceholder}</option>
|
||||
{Object.keys(PERSON_DEPARTMENTS).map((department) => (
|
||||
<option key={department} value={department}>
|
||||
{formatPersonDepartment(department, departmentCopy, fallbackCopy)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors?.department && (
|
||||
<p className="text-error">{errors.department.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-lg">
|
||||
{formCopy.emailLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="email"
|
||||
placeholder={formCopy.emailPlaceholder}
|
||||
{...register("email")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.email && <p className="text-error">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="phone" className="mb-2 block text-lg">
|
||||
{formCopy.phoneLabel}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="phone"
|
||||
placeholder={formCopy.phonePlaceholder}
|
||||
{...register("phone")}
|
||||
className={`w-full rounded-lg border px-4 py-2`}
|
||||
/>
|
||||
{errors?.phone && <p className="text-error">{errors.phone.message}</p>}
|
||||
</div>
|
||||
<SubmitButton
|
||||
copy={submitButtonCopy}
|
||||
isSubmitting={isSubmitting}
|
||||
isSubmitSuccessful={isSubmitSuccessful}
|
||||
>
|
||||
{mode === "create" ? formCopy.createSubmit : formCopy.updateSubmit}
|
||||
</SubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -4,11 +4,19 @@ import Link from "next/link"
|
||||
import PageHeader from "@/components/common/pageheader"
|
||||
import PaginationButtons from "@/components/common/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { Person } from "@/generated/prisma/client"
|
||||
import { getI18n } from "@/i18n/server"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
|
||||
import { formatPersonDepartment } from "./_components/person.copy"
|
||||
import {
|
||||
formatPersonDepartment,
|
||||
type PersonDepartmentCopy,
|
||||
type PersonFallbackCopy,
|
||||
} from "./_components/person.copy"
|
||||
import {
|
||||
formatUserRole,
|
||||
type UserFallbackCopy,
|
||||
type UserRoleCopy,
|
||||
} from "./_components/user.copy"
|
||||
|
||||
export default async function PeoplePage(props: {
|
||||
searchParams?: Promise<{
|
||||
@@ -26,6 +34,12 @@ export default async function PeoplePage(props: {
|
||||
})
|
||||
const { dictionary } = await getI18n()
|
||||
const copy = dictionary.inventory.people
|
||||
const userCopy = dictionary.admin.users
|
||||
const userStatusCopy = userCopy.status
|
||||
const userRoleLabels = userCopy.roles as UserRoleCopy
|
||||
const userFallbackCopy = userCopy.fallback as UserFallbackCopy
|
||||
const departmentCopy = copy.departments as PersonDepartmentCopy
|
||||
const personFallbackCopy = copy.fallback as PersonFallbackCopy
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -54,13 +68,19 @@ export default async function PeoplePage(props: {
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.department}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.role}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.status}
|
||||
</th>
|
||||
<th scope="col" className="p-4">
|
||||
{copy.list.columns.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{people.map((person: Person) => (
|
||||
{people.map((person) => (
|
||||
<tr key={person.id} className="border-b">
|
||||
<td className="p-4">
|
||||
{`${person.firstName} ${person.lastName}`}
|
||||
@@ -70,10 +90,26 @@ export default async function PeoplePage(props: {
|
||||
<td className="p-4">
|
||||
{formatPersonDepartment(
|
||||
person.department,
|
||||
copy.departments,
|
||||
copy.fallback,
|
||||
departmentCopy,
|
||||
personFallbackCopy,
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{person.user
|
||||
? formatUserRole(
|
||||
person.user.role,
|
||||
userRoleLabels,
|
||||
userFallbackCopy,
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{person.user
|
||||
? person.user.isActive
|
||||
? userStatusCopy.active
|
||||
: userStatusCopy.inactive
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="flex items-center gap-2 p-4">
|
||||
<Link href={`/people/${person.id}`} passHref>
|
||||
<Button
|
||||
@@ -100,7 +136,7 @@ export default async function PeoplePage(props: {
|
||||
</tbody>
|
||||
<tfoot className="border-t">
|
||||
<tr>
|
||||
<td colSpan={5} className="p-4 text-center text-sm">
|
||||
<td colSpan={7} className="p-4 text-center text-sm">
|
||||
<PaginationButtons totalPages={totalPages} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Clipboard,
|
||||
Home,
|
||||
Package,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar"
|
||||
import type { UserRole } from "@/generated/prisma/client"
|
||||
import type { Dictionary } from "@/i18n/dictionaries"
|
||||
|
||||
import { SidebarSection } from "./sidebar/sidebarSection"
|
||||
@@ -94,25 +92,11 @@ const items: SidebarItem[] = [
|
||||
|
||||
export default function AppSidebar({
|
||||
copy,
|
||||
userRole,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
copy: SidebarCopy
|
||||
userRole?: UserRole
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const visibleItems =
|
||||
userRole === "ADMIN"
|
||||
? [
|
||||
...items,
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "users",
|
||||
url: "/admin/users",
|
||||
icon: Shield,
|
||||
} satisfies SidebarItem,
|
||||
]
|
||||
: items
|
||||
|
||||
return (
|
||||
<Sidebar {...props}>
|
||||
@@ -126,7 +110,7 @@ export default function AppSidebar({
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visibleItems.map((item) => {
|
||||
{items.map((item) => {
|
||||
if (item.type === "item") {
|
||||
const isActive =
|
||||
item.url === "/"
|
||||
|
||||
@@ -328,6 +328,8 @@ export const en = {
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
department: "Department",
|
||||
role: "Role",
|
||||
status: "Status",
|
||||
actions: "Actions",
|
||||
},
|
||||
actions: {
|
||||
@@ -341,6 +343,9 @@ export const en = {
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
department: "Department",
|
||||
role: "Role",
|
||||
status: "Status",
|
||||
noUser: "No user account",
|
||||
},
|
||||
},
|
||||
new: {
|
||||
@@ -361,11 +366,17 @@ export const en = {
|
||||
emailPlaceholder: "Email",
|
||||
phoneLabel: "Phone",
|
||||
phonePlaceholder: "Phone",
|
||||
roleLabel: "Role",
|
||||
activeLabel: "Active user",
|
||||
newPasswordLabel: "New password",
|
||||
newPasswordPlaceholder: "Leave blank to keep current password",
|
||||
userAccountHeading: "User account",
|
||||
createSubmit: "Create Person",
|
||||
updateSubmit: "Update Person",
|
||||
},
|
||||
fallback: {
|
||||
unknownDepartment: "Unknown department",
|
||||
unknownStatus: "Unknown status",
|
||||
},
|
||||
departments: {
|
||||
IT: "IT",
|
||||
@@ -383,6 +394,7 @@ export const en = {
|
||||
updateSuccess: "Person updated successfully",
|
||||
updateFailure: "Failed to update person",
|
||||
duplicateEmail: "Email already exists",
|
||||
notFound: "Person not found",
|
||||
},
|
||||
schema: {
|
||||
firstNameRequired: "First name is required",
|
||||
@@ -465,8 +477,11 @@ export const en = {
|
||||
phonePlaceholder: "Phone",
|
||||
passwordLabel: "Password",
|
||||
passwordPlaceholder: "Minimum 8 characters",
|
||||
newPasswordLabel: "New password",
|
||||
newPasswordPlaceholder: "Leave blank to keep current password",
|
||||
roleLabel: "Role",
|
||||
activeLabel: "Active user",
|
||||
userAccountHeading: "User account",
|
||||
createSubmit: "Create User",
|
||||
updateSubmit: "Update User",
|
||||
},
|
||||
|
||||
@@ -333,6 +333,8 @@ export const es = {
|
||||
email: "Correo electrónico",
|
||||
phone: "Teléfono",
|
||||
department: "Departamento",
|
||||
role: "Rol",
|
||||
status: "Estado",
|
||||
actions: "Acciones",
|
||||
},
|
||||
actions: {
|
||||
@@ -346,6 +348,9 @@ export const es = {
|
||||
email: "Correo electrónico",
|
||||
phone: "Teléfono",
|
||||
department: "Departamento",
|
||||
role: "Rol",
|
||||
status: "Estado",
|
||||
noUser: "Sin cuenta de usuario",
|
||||
},
|
||||
},
|
||||
new: {
|
||||
@@ -366,11 +371,18 @@ export const es = {
|
||||
emailPlaceholder: "Correo electrónico",
|
||||
phoneLabel: "Teléfono",
|
||||
phonePlaceholder: "Teléfono",
|
||||
roleLabel: "Rol",
|
||||
activeLabel: "Usuario activo",
|
||||
newPasswordLabel: "Nueva contraseña",
|
||||
newPasswordPlaceholder:
|
||||
"Déjalo vacío para mantener la contraseña actual",
|
||||
userAccountHeading: "Cuenta de usuario",
|
||||
createSubmit: "Crear persona",
|
||||
updateSubmit: "Actualizar persona",
|
||||
},
|
||||
fallback: {
|
||||
unknownDepartment: "Departamento desconocido",
|
||||
unknownStatus: "Estado desconocido",
|
||||
},
|
||||
departments: {
|
||||
IT: "IT",
|
||||
@@ -388,6 +400,7 @@ export const es = {
|
||||
updateSuccess: "Persona actualizada correctamente",
|
||||
updateFailure: "Error al actualizar la persona",
|
||||
duplicateEmail: "El correo electrónico ya existe",
|
||||
notFound: "Persona no encontrada",
|
||||
},
|
||||
schema: {
|
||||
firstNameRequired: "El nombre es obligatorio",
|
||||
@@ -470,8 +483,12 @@ export const es = {
|
||||
phonePlaceholder: "Teléfono",
|
||||
passwordLabel: "Contraseña",
|
||||
passwordPlaceholder: "Mínimo 8 caracteres",
|
||||
newPasswordLabel: "Nueva contraseña",
|
||||
newPasswordPlaceholder:
|
||||
"Déjalo vacío para mantener la contraseña actual",
|
||||
roleLabel: "Rol",
|
||||
activeLabel: "Usuario activo",
|
||||
userAccountHeading: "Cuenta de usuario",
|
||||
createSubmit: "Crear usuario",
|
||||
updateSubmit: "Actualizar usuario",
|
||||
},
|
||||
|
||||
@@ -80,6 +80,50 @@ export const unifiedFormRoleSchema = z.enum([
|
||||
"NO_USER",
|
||||
])
|
||||
|
||||
export const unifiedUpdateRoleSchema = z.enum([
|
||||
"ADMIN",
|
||||
"MANAGER",
|
||||
"STAFF",
|
||||
"VIEWER",
|
||||
])
|
||||
|
||||
export function buildUnifiedUpdateSchema(copy: UnifiedSchemaCopy) {
|
||||
return z
|
||||
.object({
|
||||
id: z.string().nonempty(copy.idRequired),
|
||||
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
|
||||
.union([z.email({ error: copy.emailInvalid }), z.literal(""), z.null()])
|
||||
.optional(),
|
||||
phone: z.string().optional().nullable(),
|
||||
role: unifiedUpdateRoleSchema.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
password: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (
|
||||
data.role &&
|
||||
data.password !== undefined &&
|
||||
data.password !== "" &&
|
||||
data.password.length < 8
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: copy.passwordMinLength,
|
||||
path: ["password"],
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type UnifiedUpdateFormType = z.infer<
|
||||
ReturnType<typeof buildUnifiedUpdateSchema>
|
||||
>
|
||||
|
||||
export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
|
||||
return z
|
||||
.object({
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { Person, Prisma } from "@/generated/prisma/client"
|
||||
import { paginate } from "@/lib/paginate"
|
||||
import prisma from "@/lib/prisma"
|
||||
|
||||
const personWithUserSelect = {
|
||||
include: { user: true },
|
||||
} as const
|
||||
|
||||
export type PersonWithUser = Prisma.PersonGetPayload<
|
||||
typeof personWithUserSelect
|
||||
>
|
||||
|
||||
export const PersonService = {
|
||||
findAll: async (): Promise<Person[]> => {
|
||||
return prisma.person.findMany({
|
||||
@@ -19,10 +27,11 @@ export const PersonService = {
|
||||
pageSize?: number
|
||||
search?: string
|
||||
}) => {
|
||||
return paginate<Person>({
|
||||
return paginate<PersonWithUser>({
|
||||
model: prisma.person,
|
||||
page,
|
||||
pageSize,
|
||||
include: personWithUserSelect.include,
|
||||
where: {
|
||||
...(search
|
||||
? {
|
||||
@@ -47,6 +56,16 @@ export const PersonService = {
|
||||
return db.person.findUnique({ where: { id } })
|
||||
},
|
||||
|
||||
findByIdWithUser: async (
|
||||
id: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<PersonWithUser | null> => {
|
||||
return db.person.findUnique({
|
||||
where: { id },
|
||||
include: personWithUserSelect.include,
|
||||
})
|
||||
},
|
||||
|
||||
findByEmail: async (
|
||||
email: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {
|
||||
CreatePersonFormType,
|
||||
UpdatePersonFormType,
|
||||
} from "@/schemas/person.schema"
|
||||
import type { UnifiedCreateFormType } from "@/schemas/user.schema"
|
||||
import type {
|
||||
UnifiedCreateFormType,
|
||||
UnifiedUpdateFormType,
|
||||
} from "@/schemas/user.schema"
|
||||
import { PersonService } from "@/services/person.service"
|
||||
import { getUserByEmail } from "@/services/user.service"
|
||||
|
||||
@@ -217,3 +220,83 @@ export async function createPersonUserUseCase(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePersonUserUseCase(
|
||||
input: UnifiedUpdateFormType,
|
||||
): Promise<PersonUseCaseResult> {
|
||||
const {
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
email,
|
||||
phone,
|
||||
role,
|
||||
isActive,
|
||||
password,
|
||||
} = input
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.person.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return personError({ id: ["Person not found"] })
|
||||
}
|
||||
|
||||
if (email) {
|
||||
const existingPersonEmail = await PersonService.findByEmail(email, tx)
|
||||
if (existingPersonEmail && existingPersonEmail.id !== id) {
|
||||
return personError({ email: ["Email already exists"] })
|
||||
}
|
||||
}
|
||||
|
||||
await PersonService.update(
|
||||
id,
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
department,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
},
|
||||
tx,
|
||||
)
|
||||
|
||||
// If the person has a linked user, update User fields.
|
||||
if (existing.userId && existing.user) {
|
||||
const userData: Prisma.UserUpdateInput = {}
|
||||
|
||||
if (role !== undefined) {
|
||||
userData.role = role
|
||||
}
|
||||
if (isActive !== undefined) {
|
||||
userData.isActive = isActive
|
||||
}
|
||||
if (password && password.length >= 8) {
|
||||
userData.password = await getPasswordHash(password)
|
||||
}
|
||||
|
||||
if (Object.keys(userData).length > 0) {
|
||||
await tx.user.update({
|
||||
where: { id: existing.userId },
|
||||
data: userData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
const errors = uniqueErrorFor(error)
|
||||
|
||||
if (errors) {
|
||||
return personError(errors)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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
|
||||
|
||||
beforeAll(async () => {
|
||||
await startIntegrationTestDatabase()
|
||||
const prismaModule = await import("@/lib/prisma")
|
||||
prisma = prismaModule.prisma
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetIntegrationTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma?.$disconnect()
|
||||
await stopIntegrationTestDatabase()
|
||||
})
|
||||
|
||||
describe("/admin/users -> /people redirect routes", () => {
|
||||
it("does not have a /admin/users list page (route is consolidated into /people)", async () => {
|
||||
const fs = await import("node:fs/promises")
|
||||
const path = await import("node:path")
|
||||
|
||||
// /admin/users/page.tsx must still exist (as a redirect stub) — verify it's just a redirect.
|
||||
const adminUsersPage = path.join(
|
||||
process.cwd(),
|
||||
"src/app/(dashboard)/admin/users/page.tsx",
|
||||
)
|
||||
const contents = await fs.readFile(adminUsersPage, "utf-8")
|
||||
expect(contents).toMatch(/redirect\s*\(\s*["']\/people["']\s*\)/)
|
||||
})
|
||||
|
||||
it("resolves a userId back to its linked personId", async () => {
|
||||
// Build a Person<->User link to verify the redirect can find the person by userId.
|
||||
const user = await createTestUser(prisma, {
|
||||
email: "linked@example.test",
|
||||
})
|
||||
const person = await createTestPerson(prisma, {
|
||||
email: "linked@example.test",
|
||||
})
|
||||
await prisma.person.update({
|
||||
where: { id: person.id },
|
||||
data: { userId: user.id },
|
||||
})
|
||||
|
||||
const found = await prisma.person.findFirst({
|
||||
where: { userId: user.id },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
expect(found?.id).toBe(person.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import type { PrismaClient } from "@/generated/prisma/client"
|
||||
import { getPasswordHash } from "@/lib/security"
|
||||
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
stopIntegrationTestDatabase,
|
||||
} from "../helpers/test-db"
|
||||
|
||||
let prisma: PrismaClient
|
||||
let updatePersonUserUseCase: typeof import("@/use-cases/person.use-cases").updatePersonUserUseCase
|
||||
|
||||
beforeAll(async () => {
|
||||
await startIntegrationTestDatabase()
|
||||
|
||||
const prismaModule = await import("@/lib/prisma")
|
||||
const personUseCases = await import("@/use-cases/person.use-cases")
|
||||
|
||||
prisma = prismaModule.prisma
|
||||
updatePersonUserUseCase = personUseCases.updatePersonUserUseCase
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetIntegrationTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma?.$disconnect()
|
||||
await stopIntegrationTestDatabase()
|
||||
})
|
||||
|
||||
describe("updatePersonUserUseCase", () => {
|
||||
describe("person-only update", () => {
|
||||
it("updates only the Person when person has no linked User", async () => {
|
||||
const person = await createTestPerson(prisma, {
|
||||
firstName: "Old",
|
||||
lastName: "Name",
|
||||
email: "old@example.test",
|
||||
})
|
||||
|
||||
const result = await updatePersonUserUseCase({
|
||||
id: person.id,
|
||||
firstName: "New",
|
||||
lastName: "Name",
|
||||
department: "IT",
|
||||
email: "new@example.test",
|
||||
phone: "1234",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const updated = await prisma.person.findUniqueOrThrow({
|
||||
where: { id: person.id },
|
||||
})
|
||||
expect(updated).toMatchObject({
|
||||
firstName: "New",
|
||||
lastName: "Name",
|
||||
department: "IT",
|
||||
email: "new@example.test",
|
||||
phone: "1234",
|
||||
userId: null,
|
||||
})
|
||||
})
|
||||
|
||||
it("normalizes empty email to null when person has no User", async () => {
|
||||
const person = await createTestPerson(prisma, { email: null })
|
||||
|
||||
const result = await updatePersonUserUseCase({
|
||||
id: person.id,
|
||||
firstName: "Empty",
|
||||
lastName: "Email",
|
||||
department: "OTHER",
|
||||
email: "",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const updated = await prisma.person.findUniqueOrThrow({
|
||||
where: { id: person.id },
|
||||
})
|
||||
expect(updated.email).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("person+user update", () => {
|
||||
it("updates Person fields and User role/isActive when person has a User linked", async () => {
|
||||
const user = await createTestUser(prisma, {
|
||||
email: "user-update@example.test",
|
||||
name: "Old Name",
|
||||
role: "STAFF",
|
||||
isActive: true,
|
||||
})
|
||||
const person = await createTestPerson(prisma, {
|
||||
firstName: "Linked",
|
||||
lastName: "Person",
|
||||
email: "user-update@example.test",
|
||||
})
|
||||
await prisma.person.update({
|
||||
where: { id: person.id },
|
||||
data: { userId: user.id },
|
||||
})
|
||||
|
||||
const result = await updatePersonUserUseCase({
|
||||
id: person.id,
|
||||
firstName: "Linked",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
email: "user-update@example.test",
|
||||
phone: null,
|
||||
role: "ADMIN",
|
||||
isActive: false,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const updatedPerson = await prisma.person.findUniqueOrThrow({
|
||||
where: { id: person.id },
|
||||
include: { user: true },
|
||||
})
|
||||
expect(updatedPerson.department).toBe("ENGINEERING")
|
||||
expect(updatedPerson.user).toMatchObject({
|
||||
id: user.id,
|
||||
role: "ADMIN",
|
||||
isActive: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("resets the User password when password is provided", async () => {
|
||||
const user = await createTestUser(prisma, {
|
||||
email: "pw-reset@example.test",
|
||||
})
|
||||
const person = await createTestPerson(prisma, {
|
||||
email: "pw-reset@example.test",
|
||||
})
|
||||
await prisma.person.update({
|
||||
where: { id: person.id },
|
||||
data: { userId: user.id },
|
||||
})
|
||||
|
||||
const result = await updatePersonUserUseCase({
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
department: "OTHER",
|
||||
email: "pw-reset@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
isActive: true,
|
||||
password: "new-password-1",
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const updated = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
})
|
||||
const { verifyPassword } = await import("@/lib/security")
|
||||
await expect(
|
||||
verifyPassword("new-password-1", updated.password),
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it("does not change the password when password is not provided", async () => {
|
||||
const originalHash = await getPasswordHash("original-password-1")
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: "no-pw@example.test",
|
||||
name: "No PW",
|
||||
password: originalHash,
|
||||
role: "STAFF",
|
||||
isActive: true,
|
||||
},
|
||||
})
|
||||
const person = await createTestPerson(prisma, {
|
||||
email: "no-pw@example.test",
|
||||
})
|
||||
await prisma.person.update({
|
||||
where: { id: person.id },
|
||||
data: { userId: user.id },
|
||||
})
|
||||
|
||||
const result = await updatePersonUserUseCase({
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
department: "OTHER",
|
||||
email: "no-pw@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const updated = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
})
|
||||
const { verifyPassword: verify } = await import("@/lib/security")
|
||||
await expect(
|
||||
verify("original-password-1", updated.password),
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("validation errors", () => {
|
||||
it("returns error when person is not found", async () => {
|
||||
const result = await updatePersonUserUseCase({
|
||||
id: "nonexistent-id",
|
||||
firstName: "Ghost",
|
||||
lastName: "Person",
|
||||
department: "OTHER",
|
||||
email: "ghost@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.errors.id).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects duplicate email in Person table", async () => {
|
||||
const person = await createTestPerson(prisma, {
|
||||
email: "mine@example.test",
|
||||
})
|
||||
await createTestPerson(prisma, {
|
||||
email: "theirs@example.test",
|
||||
})
|
||||
|
||||
const result = await updatePersonUserUseCase({
|
||||
id: person.id,
|
||||
firstName: "Mine",
|
||||
lastName: "Person",
|
||||
department: "OTHER",
|
||||
email: "theirs@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: { email: ["Email already exists"] },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ const actionCopy = {
|
||||
updateSuccess: "Persona actualizada correctamente",
|
||||
updateFailure: "Error al actualizar la persona",
|
||||
duplicateEmail: "El correo electrónico ya existe",
|
||||
notFound: "Persona no encontrada",
|
||||
}
|
||||
|
||||
describe("person action message localization", () => {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { en } from "@/i18n/dictionaries/en"
|
||||
import { es } from "@/i18n/dictionaries/es"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
revalidatePath: vi.fn(),
|
||||
getI18n: vi.fn(),
|
||||
requireRole: vi.fn(),
|
||||
updatePersonUserUseCase: 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/person.use-cases", () => ({
|
||||
updatePersonUserUseCase: mocks.updatePersonUserUseCase,
|
||||
}))
|
||||
|
||||
import { updatePersonUserAction } from "@/actions/person.actions"
|
||||
|
||||
describe("updatePersonUserAction", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
mocks.requireRole.mockResolvedValue({ user: { id: "admin-1" } })
|
||||
})
|
||||
|
||||
describe("schema validation", () => {
|
||||
it("rejects empty id with localized idRequired error", async () => {
|
||||
const result = await updatePersonUserAction({
|
||||
id: "",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: {
|
||||
id: [es.inventory.people.schema.idRequired],
|
||||
},
|
||||
})
|
||||
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("rejects invalid email with localized emailInvalid error", async () => {
|
||||
const result = await updatePersonUserAction({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "not-an-email",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: {
|
||||
email: [es.inventory.people.schema.emailInvalid],
|
||||
},
|
||||
})
|
||||
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("rejects short password when role is provided", async () => {
|
||||
const result = await updatePersonUserAction({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
password: "corta",
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: {
|
||||
password: [es.admin.users.schema.passwordMinLength],
|
||||
},
|
||||
})
|
||||
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("rejects NO_USER role on update", async () => {
|
||||
const result = await updatePersonUserAction({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER" as unknown as "ADMIN",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("rejects invalid role enum on update", async () => {
|
||||
const result = await updatePersonUserAction({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
role: "SUPER_ADMIN" as unknown as "ADMIN",
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("use-case failure", () => {
|
||||
it("localizes duplicate email errors from the use case", async () => {
|
||||
mocks.updatePersonUserUseCase.mockResolvedValue({
|
||||
success: false,
|
||||
errors: { email: ["Email already exists"] },
|
||||
})
|
||||
|
||||
const result = await updatePersonUserAction({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "duplicate@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
errors: {
|
||||
email: [es.inventory.people.actions.duplicateEmail],
|
||||
},
|
||||
message: es.inventory.people.actions.updateFailure,
|
||||
})
|
||||
})
|
||||
|
||||
it("localizes person-not-found error from the use case", async () => {
|
||||
mocks.updatePersonUserUseCase.mockResolvedValue({
|
||||
success: false,
|
||||
errors: { id: ["Person not found"] },
|
||||
})
|
||||
|
||||
const result = await updatePersonUserAction({
|
||||
id: "missing",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success && result.errors) {
|
||||
expect(result.errors.id).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("success", () => {
|
||||
it("returns success and revalidates /people on happy path", async () => {
|
||||
mocks.updatePersonUserUseCase.mockResolvedValue({ success: true })
|
||||
|
||||
const result = await updatePersonUserAction({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: es.inventory.people.actions.updateSuccess,
|
||||
})
|
||||
expect(mocks.revalidatePath).toHaveBeenCalledWith("/people")
|
||||
})
|
||||
|
||||
it("uses English copy in en locale", async () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
mocks.updatePersonUserUseCase.mockResolvedValue({ success: true })
|
||||
|
||||
const result = await updatePersonUserAction({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
message: en.inventory.people.actions.updateSuccess,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { en } from "@/i18n/dictionaries/en"
|
||||
import { es } from "@/i18n/dictionaries/es"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getI18n: vi.fn(),
|
||||
getUsers: vi.fn(),
|
||||
getUserProfileById: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
redirect: vi.fn((url: string) => {
|
||||
throw new Error(`REDIRECT:${url}`)
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@/i18n/server", () => ({
|
||||
getI18n: mocks.getI18n,
|
||||
}))
|
||||
|
||||
vi.mock("@/services/user.service", () => ({
|
||||
getUsers: mocks.getUsers,
|
||||
getUserProfileById: mocks.getUserProfileById,
|
||||
}))
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
person: {
|
||||
findFirst: mocks.findFirst,
|
||||
},
|
||||
},
|
||||
default: {
|
||||
person: {
|
||||
findFirst: mocks.findFirst,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: mocks.redirect,
|
||||
}))
|
||||
|
||||
describe("/admin/users routes redirect to /people", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
})
|
||||
|
||||
it("redirects /admin/users to /people", async () => {
|
||||
const { default: UsersPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/page"
|
||||
)
|
||||
|
||||
expect(() => UsersPage()).toThrow("REDIRECT:/people")
|
||||
expect(mocks.getUsers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("redirects /admin/users/[userId]/edit to /people/[personId]/edit when person is found", async () => {
|
||||
mocks.findFirst.mockResolvedValue({ id: "person-99" })
|
||||
|
||||
const { default: EditUserPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
||||
)
|
||||
|
||||
await expect(
|
||||
EditUserPage({ params: Promise.resolve({ userId: "user-1" }) }),
|
||||
).rejects.toThrow("REDIRECT:/people/person-99/edit")
|
||||
expect(mocks.getUserProfileById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("redirects /admin/users/[userId]/edit to /people when person is not found", async () => {
|
||||
mocks.findFirst.mockResolvedValue(null)
|
||||
|
||||
const { default: EditUserPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
||||
)
|
||||
|
||||
await expect(
|
||||
EditUserPage({ params: Promise.resolve({ userId: "orphan-user" }) }),
|
||||
).rejects.toThrow("REDIRECT:/people")
|
||||
})
|
||||
|
||||
it("still honors Spanish locale when redirecting (does not require dictionary lookups)", async () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
|
||||
const { default: UsersPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/page"
|
||||
)
|
||||
|
||||
expect(() => UsersPage()).toThrow("REDIRECT:/people")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createElement } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { en } from "@/i18n/dictionaries/en"
|
||||
import { es } from "@/i18n/dictionaries/es"
|
||||
import type { PersonWithUser } from "@/services/person.service"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getI18n: vi.fn(),
|
||||
findByIdWithUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
personForm: vi.fn(),
|
||||
push: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/i18n/server", () => ({
|
||||
getI18n: mocks.getI18n,
|
||||
}))
|
||||
|
||||
vi.mock("@/services/person.service", () => ({
|
||||
PersonService: {
|
||||
findByIdWithUser: mocks.findByIdWithUser,
|
||||
findById: mocks.findById,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
|
||||
default: (props: unknown) => {
|
||||
mocks.personForm(props)
|
||||
return createElement("div", null, "Edit person form")
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mocks.push }),
|
||||
redirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/actions/person.actions", () => ({
|
||||
updatePersonUserAction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}))
|
||||
|
||||
const basePerson: PersonWithUser = {
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
user: null,
|
||||
}
|
||||
|
||||
const personWithUser: PersonWithUser = {
|
||||
...basePerson,
|
||||
id: "person-2",
|
||||
userId: "user-1",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.test",
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
password: "hashed",
|
||||
},
|
||||
}
|
||||
|
||||
describe("edit person page wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
})
|
||||
|
||||
it("loads the person without user, passes PersonWithoutUser to the edit form", async () => {
|
||||
mocks.findByIdWithUser.mockResolvedValue({ ...basePerson, user: null })
|
||||
|
||||
const { default: PersonEditPage } = await import(
|
||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||
)
|
||||
|
||||
renderToStaticMarkup(
|
||||
await PersonEditPage({
|
||||
params: Promise.resolve({ personId: "person-1" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mocks.findByIdWithUser).toHaveBeenCalledWith("person-1")
|
||||
expect(mocks.personForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({
|
||||
id: "person-1",
|
||||
user: null,
|
||||
}),
|
||||
formCopy: en.admin.users.form,
|
||||
schemaCopy: {
|
||||
...en.admin.users.schema,
|
||||
...en.inventory.people.schema,
|
||||
},
|
||||
roleLabels: en.admin.users.roles,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("passes Spanish copy in es locale and passes the linked User to the form", async () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
mocks.findByIdWithUser.mockResolvedValue(personWithUser)
|
||||
|
||||
const { default: PersonEditPage } = await import(
|
||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||
)
|
||||
|
||||
renderToStaticMarkup(
|
||||
await PersonEditPage({
|
||||
params: Promise.resolve({ personId: "person-2" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mocks.personForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({
|
||||
id: "person-2",
|
||||
user: expect.objectContaining({
|
||||
id: "user-1",
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
}),
|
||||
}),
|
||||
formCopy: es.admin.users.form,
|
||||
roleLabels: es.admin.users.roles,
|
||||
departmentCopy: es.inventory.people.departments,
|
||||
fallbackCopy: expect.objectContaining({
|
||||
unknownDepartment: es.inventory.people.fallback.unknownDepartment,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("renders 'Person not found' in Spanish when person does not exist", async () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
mocks.findByIdWithUser.mockResolvedValue(null)
|
||||
|
||||
const { default: PersonEditPage } = await import(
|
||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await PersonEditPage({
|
||||
params: Promise.resolve({ personId: "missing" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain("Persona no encontrada")
|
||||
expect(mocks.personForm).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,8 +5,10 @@ import { es } from "@/i18n/dictionaries/es"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getI18n: vi.fn(),
|
||||
findByIdWithUser: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
redirect: vi.fn(),
|
||||
personForm: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/i18n/server", () => ({
|
||||
@@ -15,6 +17,7 @@ vi.mock("@/i18n/server", () => ({
|
||||
|
||||
vi.mock("@/services/person.service", () => ({
|
||||
PersonService: {
|
||||
findByIdWithUser: mocks.findByIdWithUser,
|
||||
findById: mocks.findById,
|
||||
},
|
||||
}))
|
||||
@@ -29,6 +32,14 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("@/actions/person.actions", () => ({
|
||||
createNewPerson: vi.fn(),
|
||||
updatePerson: vi.fn(),
|
||||
updatePersonUserAction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
|
||||
default: (props: unknown) => {
|
||||
mocks.personForm(props)
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
@@ -44,18 +55,23 @@ describe("person pages", () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
})
|
||||
|
||||
it("renders the edit person page with Person heading and no username", async () => {
|
||||
it("renders the edit person page with Person heading and passes person to unified form", async () => {
|
||||
const { default: PersonEditPage } = await import(
|
||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||
)
|
||||
|
||||
mocks.findById.mockResolvedValue({
|
||||
mocks.findByIdWithUser.mockResolvedValue({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "ENGINEERING",
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
user: null,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
@@ -65,8 +81,15 @@ describe("person pages", () => {
|
||||
)
|
||||
|
||||
expect(html).toContain("Editar persona")
|
||||
expect(html).toContain("Actualizar persona")
|
||||
expect(html).not.toContain("Usuario")
|
||||
expect(mocks.personForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
person: expect.objectContaining({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("renders a Person not-found message on edit page", async () => {
|
||||
@@ -74,7 +97,7 @@ describe("person pages", () => {
|
||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||
)
|
||||
|
||||
mocks.findById.mockResolvedValue(null)
|
||||
mocks.findByIdWithUser.mockResolvedValue(null)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await PersonEditPage({
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { createElement } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { en } from "@/i18n/dictionaries/en"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getI18n: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
personForm: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/i18n/server", () => ({
|
||||
getI18n: mocks.getI18n,
|
||||
}))
|
||||
|
||||
vi.mock("@/services/person.service", () => ({
|
||||
PersonService: {
|
||||
findById: mocks.findById,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/app/(dashboard)/people/_components/person.form", () => ({
|
||||
default: (props: unknown) => {
|
||||
mocks.personForm(props)
|
||||
return createElement("div", null, "Person form")
|
||||
},
|
||||
}))
|
||||
|
||||
describe("person form schema wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("passes server-resolved Person schema copy into the edit person form boundary", async () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
mocks.findById.mockResolvedValue({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
department: "ENGINEERING",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
})
|
||||
|
||||
const { default: PersonEditPage } = await import(
|
||||
"@/app/(dashboard)/people/[personId]/edit/page"
|
||||
)
|
||||
|
||||
renderToStaticMarkup(
|
||||
await PersonEditPage({
|
||||
params: Promise.resolve({ personId: "person-1" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mocks.personForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: "edit",
|
||||
schemaCopy: en.inventory.people.schema,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { en } from "@/i18n/dictionaries/en"
|
||||
const mocks = vi.hoisted(() => ({
|
||||
findAllPaginated: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
findByIdWithUser: vi.fn(),
|
||||
findAllByPerson: vi.fn(),
|
||||
getI18n: vi.fn(),
|
||||
}))
|
||||
@@ -19,6 +20,7 @@ vi.mock("@/services/person.service", () => ({
|
||||
PersonService: {
|
||||
findAllPaginated: mocks.findAllPaginated,
|
||||
findById: mocks.findById,
|
||||
findByIdWithUser: mocks.findByIdWithUser,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -62,6 +64,11 @@ describe("person pages", () => {
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "ENGINEERING",
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
user: null,
|
||||
},
|
||||
],
|
||||
totalPages: 1,
|
||||
@@ -86,6 +93,72 @@ describe("person pages", () => {
|
||||
expect(html).toContain("/people/person-1/edit")
|
||||
})
|
||||
|
||||
it("renders role and status columns for people with linked users", async () => {
|
||||
const { default: PeoplePage } = await import(
|
||||
"@/app/(dashboard)/people/page"
|
||||
)
|
||||
|
||||
mocks.findAllPaginated.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "ENGINEERING",
|
||||
userId: "user-1",
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.test",
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
password: "hashed",
|
||||
movements: [],
|
||||
assignments: [],
|
||||
person: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "person-2",
|
||||
firstName: "Bob",
|
||||
lastName: "Jones",
|
||||
email: "bob@example.test",
|
||||
phone: null,
|
||||
department: "IT",
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
user: null,
|
||||
},
|
||||
],
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await PeoplePage({ searchParams: Promise.resolve({}) }),
|
||||
)
|
||||
|
||||
// Column headers from inventory.people.list.columns
|
||||
expect(html).toContain("Role")
|
||||
expect(html).toContain("Status")
|
||||
|
||||
// Person with linked user: role label + active label
|
||||
expect(html).toContain("Admin")
|
||||
expect(html).toContain("Active")
|
||||
|
||||
// Person without user: no canonical enum leaks, just placeholder
|
||||
expect(html).not.toContain(">STAFF<")
|
||||
expect(html).not.toContain(">ADMIN<")
|
||||
})
|
||||
|
||||
it("renders the person list empty state from Person copy", async () => {
|
||||
const { default: PeoplePage } = await import(
|
||||
"@/app/(dashboard)/people/page"
|
||||
@@ -108,13 +181,18 @@ describe("person pages", () => {
|
||||
"@/app/(dashboard)/people/[personId]/page"
|
||||
)
|
||||
|
||||
mocks.findById.mockResolvedValue({
|
||||
mocks.findByIdWithUser.mockResolvedValue({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "DRIVER",
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
user: null,
|
||||
})
|
||||
mocks.findAllByPerson.mockResolvedValue([
|
||||
{
|
||||
@@ -144,12 +222,87 @@ describe("person pages", () => {
|
||||
expect(html).toContain("Laptop")
|
||||
})
|
||||
|
||||
it("renders person detail User role and status when person has linked User", async () => {
|
||||
const { default: PersonInfoPage } = await import(
|
||||
"@/app/(dashboard)/people/[personId]/page"
|
||||
)
|
||||
|
||||
mocks.findByIdWithUser.mockResolvedValue({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "DRIVER",
|
||||
userId: "user-1",
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.test",
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
password: "hashed",
|
||||
movements: [],
|
||||
assignments: [],
|
||||
person: null,
|
||||
},
|
||||
})
|
||||
mocks.findAllByPerson.mockResolvedValue([])
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await PersonInfoPage({
|
||||
params: Promise.resolve({ personId: "person-1" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain("Role")
|
||||
expect(html).toContain("Status")
|
||||
expect(html).toContain("Admin")
|
||||
expect(html).toContain("Active")
|
||||
// Canonical enum value must not leak into display
|
||||
expect(html).not.toContain(">ADMIN<")
|
||||
})
|
||||
|
||||
it("renders 'No user account' placeholder for person without linked User", async () => {
|
||||
const { default: PersonInfoPage } = await import(
|
||||
"@/app/(dashboard)/people/[personId]/page"
|
||||
)
|
||||
|
||||
mocks.findByIdWithUser.mockResolvedValue({
|
||||
id: "person-1",
|
||||
firstName: "Ada",
|
||||
lastName: "Lovelace",
|
||||
email: "ada@example.test",
|
||||
phone: "1234",
|
||||
department: "DRIVER",
|
||||
userId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-01"),
|
||||
user: null,
|
||||
})
|
||||
mocks.findAllByPerson.mockResolvedValue([])
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await PersonInfoPage({
|
||||
params: Promise.resolve({ personId: "person-1" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain("No user account")
|
||||
})
|
||||
|
||||
it("renders person detail not-found from Person copy", async () => {
|
||||
const { default: PersonInfoPage } = await import(
|
||||
"@/app/(dashboard)/people/[personId]/page"
|
||||
)
|
||||
|
||||
mocks.findById.mockResolvedValue(null)
|
||||
mocks.findByIdWithUser.mockResolvedValue(null)
|
||||
mocks.findAllByPerson.mockResolvedValue([])
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { en } from "@/i18n/dictionaries/en"
|
||||
import { es } from "@/i18n/dictionaries/es"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createPersonUser: vi.fn(),
|
||||
getUserProfileById: vi.fn(),
|
||||
getI18n: vi.fn(),
|
||||
push: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/i18n/server", () => ({
|
||||
getI18n: mocks.getI18n,
|
||||
}))
|
||||
|
||||
vi.mock("@/actions/person.actions", () => ({
|
||||
createPersonUserAction: mocks.createPersonUser,
|
||||
}))
|
||||
|
||||
vi.mock("@/actions/user.actions", () => ({
|
||||
updateUserAction: vi.fn(),
|
||||
resetUserPasswordAction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/services/person.service", () => ({
|
||||
PersonService: {
|
||||
findById: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/services/user.service", () => ({
|
||||
getUserProfileById: mocks.getUserProfileById,
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mocks.push,
|
||||
}),
|
||||
notFound: () => {
|
||||
throw new Error("NOT_FOUND")
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mocks.toastError,
|
||||
success: mocks.toastSuccess,
|
||||
},
|
||||
}))
|
||||
|
||||
describe("new user form localization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
})
|
||||
|
||||
it("renders new user page with localized title and unified form labels in Spanish", async () => {
|
||||
const { default: NewUserPage } = await import(
|
||||
"@/app/(dashboard)/people/new/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(await NewUserPage())
|
||||
|
||||
// Title
|
||||
expect(html).toContain("Nueva persona")
|
||||
|
||||
// Person field labels
|
||||
expect(html).toContain("Nombre")
|
||||
expect(html).toContain("Apellido")
|
||||
expect(html).toContain("Departamento")
|
||||
expect(html).toContain("Teléfono")
|
||||
|
||||
// User field labels
|
||||
expect(html).toContain("Correo electrónico")
|
||||
expect(html).toContain("Contraseña")
|
||||
expect(html).toContain("Rol")
|
||||
|
||||
// Role labels (display) with canonical values
|
||||
expect(html).toContain("Administrador")
|
||||
expect(html).toContain("Gerente")
|
||||
expect(html).toContain("Personal")
|
||||
expect(html).toContain("Visor")
|
||||
expect(html).toContain("Sin cuenta de usuario")
|
||||
|
||||
// Submit button text
|
||||
expect(html).toContain("Crear usuario")
|
||||
})
|
||||
|
||||
it("renders new user page with English unified form labels in English locale", async () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
|
||||
const { default: NewUserPage } = await import(
|
||||
"@/app/(dashboard)/people/new/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(await NewUserPage())
|
||||
|
||||
expect(html).toContain("New Person")
|
||||
|
||||
// Person fields
|
||||
expect(html).toContain("First Name")
|
||||
expect(html).toContain("Last Name")
|
||||
expect(html).toContain("Department")
|
||||
expect(html).toContain("Phone")
|
||||
|
||||
// User fields
|
||||
expect(html).toContain("Email")
|
||||
expect(html).toContain("Password")
|
||||
expect(html).toContain("Role")
|
||||
|
||||
expect(html).toContain("Create User")
|
||||
expect(html).toContain("Admin")
|
||||
expect(html).toContain("Manager")
|
||||
expect(html).toContain("Staff")
|
||||
expect(html).toContain("Viewer")
|
||||
expect(html).toContain("No user account")
|
||||
})
|
||||
|
||||
it("keeps canonical role values in option value attributes including NO_USER, not localized labels", async () => {
|
||||
const { default: NewUserPage } = await import(
|
||||
"@/app/(dashboard)/people/new/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(await NewUserPage())
|
||||
|
||||
// Canonical values must be in value attributes
|
||||
expect(html).toContain('value="ADMIN"')
|
||||
expect(html).toContain('value="MANAGER"')
|
||||
expect(html).toContain('value="STAFF"')
|
||||
expect(html).toContain('value="VIEWER"')
|
||||
expect(html).toContain('value="NO_USER"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("edit user form localization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
mocks.getUserProfileById.mockResolvedValue({
|
||||
id: "user-1",
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.test",
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("renders edit user page with localized title, form labels, and reset-password section in Spanish", async () => {
|
||||
const { default: EditUserPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await EditUserPage({
|
||||
params: Promise.resolve({ userId: "user-1" }),
|
||||
}),
|
||||
)
|
||||
|
||||
// Title
|
||||
expect(html).toContain("Editar usuario")
|
||||
|
||||
// Form labels
|
||||
expect(html).toContain("Nombre")
|
||||
expect(html).toContain("Nueva contraseña")
|
||||
|
||||
// Role labels with canonical values
|
||||
expect(html).toContain("Administrador")
|
||||
expect(html).toContain('value="ADMIN"')
|
||||
|
||||
// Active user checkbox label
|
||||
expect(html).toContain("Usuario activo")
|
||||
|
||||
// Submit button
|
||||
expect(html).toContain("Actualizar usuario")
|
||||
|
||||
// Reset password section
|
||||
expect(html).toContain("Restablecer contraseña")
|
||||
expect(html).toContain("Nueva contraseña")
|
||||
expect(html).toContain("Mínimo 8 caracteres")
|
||||
})
|
||||
|
||||
it("renders edit user page with English labels in English locale", async () => {
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
|
||||
const { default: EditUserPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await EditUserPage({
|
||||
params: Promise.resolve({ userId: "user-1" }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain("Edit User")
|
||||
expect(html).toContain("Active user")
|
||||
expect(html).toContain("Update User")
|
||||
expect(html).toContain("Reset password")
|
||||
expect(html).toContain("New password")
|
||||
expect(html).toContain("Reset Password")
|
||||
})
|
||||
|
||||
it("renders edit user form with role option values as canonical enums regardless of locale", async () => {
|
||||
const { default: EditUserPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/[userId]/edit/page"
|
||||
)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await EditUserPage({
|
||||
params: Promise.resolve({ userId: "user-1" }),
|
||||
}),
|
||||
)
|
||||
|
||||
// Canonical role values
|
||||
expect(html).toContain('value="ADMIN"')
|
||||
expect(html).toContain('value="MANAGER"')
|
||||
expect(html).toContain('value="STAFF"')
|
||||
expect(html).toContain('value="VIEWER"')
|
||||
|
||||
// Spanish labels for roles
|
||||
expect(html).toContain("Administrador")
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import { createElement } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { en } from "@/i18n/dictionaries/en"
|
||||
import { es } from "@/i18n/dictionaries/es"
|
||||
import type { getUsers as _getUsers } from "@/services/user.service"
|
||||
|
||||
type UserData = Awaited<ReturnType<typeof _getUsers>>["data"][number]
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getUsers: vi.fn(),
|
||||
getI18n: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@/i18n/server", () => ({
|
||||
getI18n: mocks.getI18n,
|
||||
}))
|
||||
|
||||
vi.mock("@/services/user.service", () => ({
|
||||
getUsers: mocks.getUsers,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/common/pageheader", () => ({
|
||||
default: ({ title, addLabel }: { title?: string; addLabel?: string }) =>
|
||||
createElement(
|
||||
"header",
|
||||
null,
|
||||
[title, addLabel].filter(Boolean).join(" | "),
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock("@/components/common/pagination", () => ({
|
||||
default: ({ totalPages }: { totalPages: number }) =>
|
||||
createElement("nav", { "aria-label": "Pagination" }, totalPages),
|
||||
}))
|
||||
|
||||
describe("user pages localization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
||||
})
|
||||
|
||||
it("renders the user list in Spanish with localized headers, status/role labels, and unchanged user data", async () => {
|
||||
const { default: UsersPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/page"
|
||||
)
|
||||
|
||||
mocks.getUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.test",
|
||||
role: "ADMIN",
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
name: "Grace Hopper",
|
||||
email: "grace@example.test",
|
||||
role: "STAFF",
|
||||
isActive: false,
|
||||
},
|
||||
],
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await UsersPage({ searchParams: Promise.resolve({}) }),
|
||||
)
|
||||
|
||||
// Title and add label
|
||||
expect(html).toContain("Usuarios")
|
||||
expect(html).toContain("Agregar usuario")
|
||||
|
||||
// Table headers from dictionary
|
||||
expect(html).toContain("Nombre")
|
||||
expect(html).toContain("Correo electrónico")
|
||||
expect(html).toContain("Rol")
|
||||
expect(html).toContain("Estado")
|
||||
expect(html).toContain("Acciones")
|
||||
|
||||
// Status labels from dictionary (display-only, not canonical)
|
||||
expect(html).toContain("Activo")
|
||||
expect(html).toContain("Inactivo")
|
||||
|
||||
// Role labels from dictionary (display-only, not canonical)
|
||||
expect(html).toContain("Administrador")
|
||||
expect(html).toContain("Personal")
|
||||
|
||||
// User data is never translated
|
||||
expect(html).toContain("Ada Lovelace")
|
||||
expect(html).toContain("ada@example.test")
|
||||
expect(html).toContain("Grace Hopper")
|
||||
expect(html).toContain("grace@example.test")
|
||||
|
||||
// Canonical role values must NOT appear as display text
|
||||
expect(html).not.toContain(">ADMIN<")
|
||||
expect(html).not.toContain(">STAFF<")
|
||||
})
|
||||
|
||||
it("renders the localized user empty state when no users exist", async () => {
|
||||
const { default: UsersPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/page"
|
||||
)
|
||||
|
||||
mocks.getUsers.mockResolvedValue({
|
||||
data: [],
|
||||
totalPages: 0,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await UsersPage({ searchParams: Promise.resolve({}) }),
|
||||
)
|
||||
|
||||
expect(html).toContain("No se encontraron usuarios.")
|
||||
})
|
||||
|
||||
it("renders the user list in English with English dictionary labels", async () => {
|
||||
const { default: UsersPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/page"
|
||||
)
|
||||
|
||||
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
|
||||
mocks.getUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.test",
|
||||
role: "MANAGER",
|
||||
isActive: true,
|
||||
},
|
||||
],
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await UsersPage({ searchParams: Promise.resolve({}) }),
|
||||
)
|
||||
|
||||
// English dictionary labels
|
||||
expect(html).toContain("Users")
|
||||
expect(html).toContain("Add User")
|
||||
expect(html).toContain("Name")
|
||||
expect(html).toContain("Email")
|
||||
expect(html).toContain("Role")
|
||||
expect(html).toContain("Status")
|
||||
expect(html).toContain("Actions")
|
||||
expect(html).toContain("Active")
|
||||
expect(html).toContain("Manager")
|
||||
|
||||
// Canonical enum value must NOT appear as display text
|
||||
expect(html).not.toContain(">MANAGER<")
|
||||
})
|
||||
|
||||
it("renders unknown role via fallback when role is not in dictionary", async () => {
|
||||
const { default: UsersPage } = await import(
|
||||
"@/app/(dashboard)/admin/users/page"
|
||||
)
|
||||
|
||||
mocks.getUsers.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "user-1",
|
||||
name: "Test User",
|
||||
email: "test@example.test",
|
||||
role: "UNKNOWN_ROLE",
|
||||
isActive: true,
|
||||
} as unknown as UserData,
|
||||
],
|
||||
totalPages: 1,
|
||||
})
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
await UsersPage({ searchParams: Promise.resolve({}) }),
|
||||
)
|
||||
|
||||
// Unknown role should use fallback
|
||||
expect(html).toContain("Rol desconocido")
|
||||
})
|
||||
})
|
||||
@@ -52,6 +52,7 @@ describe("formatPersonDepartment helper", () => {
|
||||
|
||||
const fallbackCopy = {
|
||||
unknownDepartment: "Departamento desconocido",
|
||||
unknownStatus: "Estado desconocido",
|
||||
}
|
||||
|
||||
it("formats known department values with localized labels", () => {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createElement } from "react"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { en } from "@/i18n/dictionaries/en"
|
||||
import { es } from "@/i18n/dictionaries/es"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
usePathname: vi.fn(() => "/people"),
|
||||
Link: ({ href, children }: { href: string; children: React.ReactNode }) =>
|
||||
createElement("a", { href }, children),
|
||||
}))
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: mocks.usePathname,
|
||||
redirect: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: mocks.Link,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui/sidebar", () => {
|
||||
const Pass = ({ children }: { children: React.ReactNode }) => children
|
||||
return {
|
||||
Sidebar: Pass,
|
||||
SidebarContent: Pass,
|
||||
SidebarGroup: Pass,
|
||||
SidebarGroupContent: Pass,
|
||||
SidebarHeader: Pass,
|
||||
SidebarMenu: Pass,
|
||||
SidebarMenuButton: ({
|
||||
children: buttonChildren,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => buttonChildren,
|
||||
SidebarMenuItem: ({
|
||||
children: itemChildren,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) => createElement("li", null, itemChildren),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/components/layout/sidebar/sidebarSection", () => ({
|
||||
SidebarSection: ({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string
|
||||
items: { title: string; url: string }[]
|
||||
}) =>
|
||||
createElement(
|
||||
"section",
|
||||
null,
|
||||
title,
|
||||
...items.map((sub) =>
|
||||
createElement("a", { key: sub.url, href: sub.url }, sub.title),
|
||||
),
|
||||
),
|
||||
}))
|
||||
|
||||
describe("app sidebar (consolidated people management)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.usePathname.mockReturnValue("/people")
|
||||
})
|
||||
|
||||
it("does NOT render a 'users' link in the sidebar (consolidated under /people)", async () => {
|
||||
const { default: AppSidebar } = await import("@/components/layout/sidebar")
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(AppSidebar, { copy: en.layout.sidebar }),
|
||||
)
|
||||
|
||||
// No /admin/users link must exist anywhere
|
||||
expect(html).not.toContain("/admin/users")
|
||||
expect(html).not.toContain(">Users<")
|
||||
})
|
||||
|
||||
it("renders 'People' (not 'Users') in English", async () => {
|
||||
const { default: AppSidebar } = await import("@/components/layout/sidebar")
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(AppSidebar, { copy: en.layout.sidebar }),
|
||||
)
|
||||
|
||||
expect(html).toContain("People")
|
||||
expect(html).toContain("/people")
|
||||
})
|
||||
|
||||
it("renders 'Personas' (not 'Usuarios') in Spanish", async () => {
|
||||
const { default: AppSidebar } = await import("@/components/layout/sidebar")
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(AppSidebar, { copy: es.layout.sidebar }),
|
||||
)
|
||||
|
||||
expect(html).toContain("Personas")
|
||||
expect(html).toContain("/people")
|
||||
expect(html).not.toContain("Usuarios")
|
||||
})
|
||||
|
||||
it("marks the /people entry as active when pathname starts with /people", async () => {
|
||||
const { default: AppSidebar } = await import("@/components/layout/sidebar")
|
||||
|
||||
mocks.usePathname.mockReturnValue("/people/person-1/edit")
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(AppSidebar, { copy: en.layout.sidebar }),
|
||||
)
|
||||
|
||||
// The /people link must still be present and reachable
|
||||
expect(html).toContain("/people")
|
||||
})
|
||||
})
|
||||
@@ -40,8 +40,11 @@ describe("admin users dictionary", () => {
|
||||
phonePlaceholder: "Phone",
|
||||
passwordLabel: "Password",
|
||||
passwordPlaceholder: "Minimum 8 characters",
|
||||
newPasswordLabel: "New password",
|
||||
newPasswordPlaceholder: "Leave blank to keep current password",
|
||||
roleLabel: "Role",
|
||||
activeLabel: "Active user",
|
||||
userAccountHeading: "User account",
|
||||
createSubmit: "Create User",
|
||||
updateSubmit: "Update User",
|
||||
})
|
||||
@@ -132,8 +135,11 @@ describe("admin users dictionary", () => {
|
||||
phonePlaceholder: "Teléfono",
|
||||
passwordLabel: "Contraseña",
|
||||
passwordPlaceholder: "Mínimo 8 caracteres",
|
||||
newPasswordLabel: "Nueva contraseña",
|
||||
newPasswordPlaceholder: "Déjalo vacío para mantener la contraseña actual",
|
||||
roleLabel: "Rol",
|
||||
activeLabel: "Usuario activo",
|
||||
userAccountHeading: "Cuenta de usuario",
|
||||
createSubmit: "Crear usuario",
|
||||
updateSubmit: "Actualizar usuario",
|
||||
})
|
||||
|
||||
@@ -701,6 +701,8 @@ describe("i18n dictionaries", () => {
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
department: "Department",
|
||||
role: "Role",
|
||||
status: "Status",
|
||||
actions: "Actions",
|
||||
},
|
||||
actions: {
|
||||
@@ -714,6 +716,9 @@ describe("i18n dictionaries", () => {
|
||||
email: "Email",
|
||||
phone: "Phone",
|
||||
department: "Department",
|
||||
role: "Role",
|
||||
status: "Status",
|
||||
noUser: "No user account",
|
||||
},
|
||||
},
|
||||
new: {
|
||||
@@ -734,11 +739,17 @@ describe("i18n dictionaries", () => {
|
||||
emailPlaceholder: "Email",
|
||||
phoneLabel: "Phone",
|
||||
phonePlaceholder: "Phone",
|
||||
roleLabel: "Role",
|
||||
activeLabel: "Active user",
|
||||
newPasswordLabel: "New password",
|
||||
newPasswordPlaceholder: "Leave blank to keep current password",
|
||||
userAccountHeading: "User account",
|
||||
createSubmit: "Create Person",
|
||||
updateSubmit: "Update Person",
|
||||
},
|
||||
fallback: {
|
||||
unknownDepartment: "Unknown department",
|
||||
unknownStatus: "Unknown status",
|
||||
},
|
||||
departments: {
|
||||
IT: "IT",
|
||||
@@ -756,6 +767,7 @@ describe("i18n dictionaries", () => {
|
||||
updateSuccess: "Person updated successfully",
|
||||
updateFailure: "Failed to update person",
|
||||
duplicateEmail: "Email already exists",
|
||||
notFound: "Person not found",
|
||||
},
|
||||
schema: {
|
||||
firstNameRequired: "First name is required",
|
||||
@@ -777,6 +789,8 @@ describe("i18n dictionaries", () => {
|
||||
email: "Correo electrónico",
|
||||
phone: "Teléfono",
|
||||
department: "Departamento",
|
||||
role: "Rol",
|
||||
status: "Estado",
|
||||
actions: "Acciones",
|
||||
},
|
||||
actions: {
|
||||
@@ -790,6 +804,9 @@ describe("i18n dictionaries", () => {
|
||||
email: "Correo electrónico",
|
||||
phone: "Teléfono",
|
||||
department: "Departamento",
|
||||
role: "Rol",
|
||||
status: "Estado",
|
||||
noUser: "Sin cuenta de usuario",
|
||||
},
|
||||
},
|
||||
new: {
|
||||
@@ -810,11 +827,18 @@ describe("i18n dictionaries", () => {
|
||||
emailPlaceholder: "Correo electrónico",
|
||||
phoneLabel: "Teléfono",
|
||||
phonePlaceholder: "Teléfono",
|
||||
roleLabel: "Rol",
|
||||
activeLabel: "Usuario activo",
|
||||
newPasswordLabel: "Nueva contraseña",
|
||||
newPasswordPlaceholder:
|
||||
"Déjalo vacío para mantener la contraseña actual",
|
||||
userAccountHeading: "Cuenta de usuario",
|
||||
createSubmit: "Crear persona",
|
||||
updateSubmit: "Actualizar persona",
|
||||
},
|
||||
fallback: {
|
||||
unknownDepartment: "Departamento desconocido",
|
||||
unknownStatus: "Estado desconocido",
|
||||
},
|
||||
departments: {
|
||||
IT: "IT",
|
||||
@@ -832,6 +856,7 @@ describe("i18n dictionaries", () => {
|
||||
updateSuccess: "Persona actualizada correctamente",
|
||||
updateFailure: "Error al actualizar la persona",
|
||||
duplicateEmail: "El correo electrónico ya existe",
|
||||
notFound: "Persona no encontrada",
|
||||
},
|
||||
schema: {
|
||||
firstNameRequired: "El nombre es obligatorio",
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
buildUnifiedUpdateSchema,
|
||||
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 validPersonOnly = {
|
||||
id: "person-1",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
}
|
||||
|
||||
const validPersonWithUser = {
|
||||
...validPersonOnly,
|
||||
role: "ADMIN" as const,
|
||||
isActive: true,
|
||||
}
|
||||
|
||||
describe("buildUnifiedUpdateSchema", () => {
|
||||
describe("person-only update (no user fields provided)", () => {
|
||||
it("accepts person fields without role/isActive/password when person has no User", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse(validPersonOnly)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe("person-1")
|
||||
expect(result.data.firstName).toBe("John")
|
||||
expect(result.data.role).toBeUndefined()
|
||||
expect(result.data.isActive).toBeUndefined()
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts person fields with isActive=false when person has no User", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({ ...validPersonOnly, isActive: false })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects empty id", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
id: "",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.id).toContain(
|
||||
enCopy.idRequired,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("person+user update (when person has User linked)", () => {
|
||||
it("accepts person and user fields with role and isActive when no password change", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse(validPersonWithUser)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.role).toBe("ADMIN")
|
||||
expect(result.data.isActive).toBe(true)
|
||||
expect(result.data.password).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts password reset (>= 8 chars) when role is provided", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUser,
|
||||
password: "newpassword1",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.password).toBe("newpassword1")
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects short password when role is provided and password is non-empty", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUser,
|
||||
password: "short",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.password).toContain(
|
||||
enCopy.passwordMinLength,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts empty password string as 'do not change' (ignored)", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUser,
|
||||
password: "",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects invalid role enum", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUser,
|
||||
role: "SUPER_ADMIN",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("rejects NO_USER role on update (cannot remove user from existing person)", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUser,
|
||||
role: "NO_USER" as unknown as "ADMIN",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("uses localized password error message", () => {
|
||||
const schema = buildUnifiedUpdateSchema(esCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonWithUser,
|
||||
password: "corta",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.password).toContain(
|
||||
esCopy.passwordMinLength,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("email validation", () => {
|
||||
it("rejects invalid email format", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({
|
||||
...validPersonOnly,
|
||||
email: "not-an-email",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) {
|
||||
expect(result.error.flatten().fieldErrors.email).toContain(
|
||||
enCopy.emailInvalid,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("accepts null email (person can have no email)", () => {
|
||||
const schema = buildUnifiedUpdateSchema(enCopy)
|
||||
const result = schema.safeParse({ ...validPersonOnly, email: null })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user