refactor: consolidate admin/users management under /people
This commit is contained in:
@@ -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