feat(people): cutover Person from department to teamId (Slice 2/2) #5

Merged
aferrer merged 12 commits from feature/add-teams-slice-2 into feature/add-teams 2026-06-26 00:29:09 +00:00
52 changed files with 836 additions and 488 deletions
@@ -0,0 +1,41 @@
BEGIN;
-- Seed legacy teams from the old PersonDepartment enum English display names.
INSERT INTO "Team" ("id", "name", "createdAt", "updatedAt")
VALUES
(gen_random_uuid(), 'IT', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Engineering', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Logistics', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Traffic', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Driver', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Administration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Sales', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
(gen_random_uuid(), 'Other', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (lower("name")) DO NOTHING;
-- Backfill Person.teamId from the legacy Person.department enum values.
UPDATE "Person"
SET "teamId" = (
SELECT "id" FROM "Team" WHERE lower("name") = lower(CASE "department"
WHEN 'IT' THEN 'IT'
WHEN 'ENGINEERING' THEN 'Engineering'
WHEN 'LOGISTICS' THEN 'Logistics'
WHEN 'TRAFFIC' THEN 'Traffic'
WHEN 'DRIVER' THEN 'Driver'
WHEN 'ADMINISTRATION' THEN 'Administration'
WHEN 'SALES' THEN 'Sales'
WHEN 'OTHER' THEN 'Other'
END)
)
WHERE "department" IS NOT NULL;
-- Drop the legacy department index.
DROP INDEX "Person_department_deletedAt_idx";
-- Drop the legacy department column.
ALTER TABLE "Person" DROP COLUMN "department";
-- Drop the legacy enum type.
DROP TYPE "PersonDepartment";
COMMIT;
+3 -16
View File
@@ -110,22 +110,10 @@ model UserInvitation {
// PEOPLE // PEOPLE
// ====================================================== // ======================================================
enum PersonDepartment {
IT
ENGINEERING
LOGISTICS
TRAFFIC
DRIVER
ADMINISTRATION
SALES
OTHER
}
model Person { model Person {
id String @id @default(uuid(7)) @db.Uuid id String @id @default(uuid(7)) @db.Uuid
firstName String firstName String
lastName String lastName String
department PersonDepartment?
email String? email String?
phone String? phone String?
@@ -143,7 +131,6 @@ model Person {
assignments Assignment[] assignments Assignment[]
@@index([lastName, firstName]) @@index([lastName, firstName])
@@index([department, deletedAt])
@@index([teamId, deletedAt]) @@index([teamId, deletedAt])
@@index([teamId]) @@index([teamId])
@@index([deletedAt]) @@index([deletedAt])
+14 -6
View File
@@ -35,14 +35,18 @@ export async function createAssignment(formData: CreateAssignmentFormType) {
try { try {
const createdBy = await getAuthenticatedUserId() const createdBy = await getAuthenticatedUserId()
const { itemId, quantity, notes } = validatedFields.data
if (!itemId || quantity == null) {
throw new Error("Missing required assignment fields")
}
const result = await createAssignmentUseCase({ const result = await createAssignmentUseCase({
...validatedFields.data, ...validatedFields.data,
lines: [ lines: [
{ {
itemId: validatedFields.data.itemId!, itemId,
quantity: validatedFields.data.quantity!, quantity,
notes: validatedFields.data.notes, notes,
}, },
], ],
actorId: createdBy, actorId: createdBy,
@@ -86,14 +90,18 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) {
try { try {
const createdBy = await getAuthenticatedUserId() const createdBy = await getAuthenticatedUserId()
const { itemId, quantity, notes } = validatedFields.data
if (!itemId || quantity == null) {
throw new Error("Missing required assignment fields")
}
const result = await updateAssignmentUseCase({ const result = await updateAssignmentUseCase({
...validatedFields.data, ...validatedFields.data,
lines: [ lines: [
{ {
itemId: validatedFields.data.itemId!, itemId,
quantity: validatedFields.data.quantity!, quantity,
notes: validatedFields.data.notes, notes,
}, },
], ],
actorId: createdBy, actorId: createdBy,
-1
View File
@@ -303,7 +303,6 @@ export async function importItems(formData: ImportFormType) {
lastName, lastName,
email: undefined, email: undefined,
phone: "", phone: "",
department: "OTHER",
}) })
} else { } else {
newPerson = existingPerson.data[0] newPerson = existingPerson.data[0]
+1
View File
@@ -6,6 +6,7 @@ type FieldErrors = Record<string, string[]>
const personErrorMessageKeys = { const personErrorMessageKeys = {
"Email already exists": "duplicateEmail", "Email already exists": "duplicateEmail",
"Team not found": "teamNotFound",
} as const satisfies Record<string, keyof PersonActionCopy> } as const satisfies Record<string, keyof PersonActionCopy>
function isPersonErrorMessage( function isPersonErrorMessage(
+1 -1
View File
@@ -76,7 +76,7 @@ export function localizeUnifiedCreateFieldErrors(
return message return message
if (field === "lastName" && message === schemaCopy.lastNameRequired) if (field === "lastName" && message === schemaCopy.lastNameRequired)
return message return message
if (field === "department" && message === schemaCopy.departmentRequired) if (field === "teamId" && message === schemaCopy.teamIdInvalid)
return message return message
if (field === "email" && message === schemaCopy.emailInvalid) if (field === "email" && message === schemaCopy.emailInvalid)
return message return message
@@ -7,7 +7,10 @@ import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server" import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service" import { AssetService } from "@/services/asset.service"
import type { AssetDetailCopy, AssetStatusCopy } from "../_components/asset.copy" import type {
AssetDetailCopy,
AssetStatusCopy,
} from "../_components/asset.copy"
function formatAssetStatus( function formatAssetStatus(
status: string, status: string,
@@ -77,7 +80,9 @@ export default async function AssetDetailPage({
<dd>{asset.serialNumber}</dd> <dd>{asset.serialNumber}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">{copy.labels.assetTag}</dt> <dt className="text-sm text-muted-foreground">
{copy.labels.assetTag}
</dt>
<dd>{asset.assetTag ?? missingValue}</dd> <dd>{asset.assetTag ?? missingValue}</dd>
</div> </div>
<div> <div>
@@ -119,11 +124,19 @@ export default async function AssetDetailPage({
<dd>{asset.notes ?? missingValue}</dd> <dd>{asset.notes ?? missingValue}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">{copy.labels.status}</dt> <dt className="text-sm text-muted-foreground">
<dd>{formatAssetStatus(asset.status, statusCopy, { unknownStatus: missingValue })}</dd> {copy.labels.status}
</dt>
<dd>
{formatAssetStatus(asset.status, statusCopy, {
unknownStatus: missingValue,
})}
</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">{copy.labels.person}</dt> <dt className="text-sm text-muted-foreground">
{copy.labels.person}
</dt>
<dd>{formatPersonName(asset.assignment?.person, missingValue)}</dd> <dd>{formatPersonName(asset.assignment?.person, missingValue)}</dd>
</div> </div>
</dl> </dl>
@@ -1,5 +1,6 @@
import { getI18n } from "@/i18n/server" import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service" import { PersonService } from "@/services/person.service"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import EditPersonForm from "../../_components/edit.person.form" import EditPersonForm from "../../_components/edit.person.form"
@@ -13,6 +14,7 @@ export default async function PersonEditPage({
const personCopy = dictionary.inventory.people const personCopy = dictionary.inventory.people
const userCopy = dictionary.admin.users const userCopy = dictionary.admin.users
const person = await PersonService.findByIdWithUser(personId) const person = await PersonService.findByIdWithUser(personId)
const teams = await listTeamsUseCase()
if (!person) { if (!person) {
return <div>{personCopy.edit.notFound}</div> return <div>{personCopy.edit.notFound}</div>
@@ -28,10 +30,8 @@ export default async function PersonEditPage({
formCopy={userCopy.form} formCopy={userCopy.form}
schemaCopy={{ ...userCopy.schema, ...personCopy.schema }} schemaCopy={{ ...userCopy.schema, ...personCopy.schema }}
roleLabels={userCopy.roles} roleLabels={userCopy.roles}
userFallbackCopy={userCopy.fallback}
departmentCopy={personCopy.departments}
fallbackCopy={personCopy.fallback}
submitButtonCopy={dictionary.common.submitButton} submitButtonCopy={dictionary.common.submitButton}
teams={teams}
/> />
</div> </div>
) )
+2 -11
View File
@@ -4,7 +4,6 @@ import { getI18n } from "@/i18n/server"
import { AssignmentService } from "@/services/assignment.service" import { AssignmentService } from "@/services/assignment.service"
import { PersonService } from "@/services/person.service" import { PersonService } from "@/services/person.service"
import { formatPersonDepartment } from "../_components/person.copy"
import { import {
formatUserRole, formatUserRole,
type UserFallbackCopy, type UserFallbackCopy,
@@ -45,16 +44,8 @@ export default async function PersonInfoPage({
<span>{person.phone}</span> <span>{person.phone}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600"> <span className="text-gray-600">{copy.detail.labels.team}</span>
{copy.detail.labels.department} <span>{person.team?.name ?? copy.fallback.noTeam}</span>
</span>
<span>
{formatPersonDepartment(
person.department,
copy.departments,
copy.fallback,
)}
</span>
</div> </div>
{person.user ? ( {person.user ? (
<> <>
@@ -12,20 +12,16 @@ import {
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { UserStatus } from "@/generated/prisma/client" import { UserStatus } from "@/generated/prisma/client"
import { PERSON_DEPARTMENTS } from "@/lib/constants"
import { import {
buildUnifiedUpdateSchema, buildUnifiedUpdateSchema,
type UnifiedSchemaCopy, type UnifiedSchemaCopy,
type UnifiedUpdateFormType, type UnifiedUpdateFormType,
} from "@/schemas/user.schema" } from "@/schemas/user.schema"
import type { PersonWithUser } from "@/services/person.service" import type { PersonWithUser } from "@/services/person.service"
import type { TeamSummary } from "@/types"
import { import {
formatPersonDepartment,
formatUserRole, formatUserRole,
type PersonDepartmentCopy,
type PersonFallbackCopy,
type UserFallbackCopy,
type UserFormCopy, type UserFormCopy,
type UserRoleCopy, type UserRoleCopy,
} from "./user.copy" } from "./user.copy"
@@ -35,19 +31,15 @@ export default function EditPersonForm({
formCopy, formCopy,
schemaCopy, schemaCopy,
roleLabels, roleLabels,
userFallbackCopy,
departmentCopy,
fallbackCopy,
submitButtonCopy, submitButtonCopy,
teams,
}: { }: {
person: PersonWithUser person: PersonWithUser
formCopy: UserFormCopy formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy roleLabels: UserRoleCopy
userFallbackCopy: UserFallbackCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
submitButtonCopy: SubmitButtonCopy submitButtonCopy: SubmitButtonCopy
teams: TeamSummary[]
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo( const schema = useMemo(
@@ -68,7 +60,7 @@ export default function EditPersonForm({
id: person.id, id: person.id,
firstName: person.firstName, firstName: person.firstName,
lastName: person.lastName, lastName: person.lastName,
department: person.department ?? "OTHER", teamId: person.teamId ?? null,
email: person.email ?? "", email: person.email ?? "",
phone: person.phone ?? "", phone: person.phone ?? "",
...(hasUser && user ...(hasUser && user
@@ -116,12 +108,11 @@ export default function EditPersonForm({
placeholder={formCopy.lastNamePlaceholder} placeholder={formCopy.lastNamePlaceholder}
register={register("lastName")} register={register("lastName")}
/> />
<DepartmentSelect <TeamSelect
error={errors.department?.message} error={errors.teamId?.message}
formCopy={formCopy} formCopy={formCopy}
departmentCopy={departmentCopy} register={register("teamId")}
fallbackCopy={fallbackCopy} teams={teams}
register={register("department")}
/> />
<TextInput <TextInput
error={errors.email?.message} error={errors.email?.message}
@@ -238,33 +229,31 @@ function RoleSelect({
) )
} }
function DepartmentSelect({ function TeamSelect({
error, error,
formCopy, formCopy,
departmentCopy,
fallbackCopy,
register, register,
teams,
}: { }: {
error?: string error?: string
formCopy: UserFormCopy formCopy: UserFormCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
register: UseFormRegisterReturn register: UseFormRegisterReturn
teams: TeamSummary[]
}) { }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="department" className="mb-2 block text-lg"> <label htmlFor="teamId" className="mb-2 block text-lg">
{formCopy.departmentLabel} {formCopy.teamLabel}
</label> </label>
<select <select
id="department" id="teamId"
{...register} {...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`} className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
> >
<option value="">{formCopy.departmentPlaceholder}</option> <option value="">{formCopy.teamPlaceholder}</option>
{Object.keys(PERSON_DEPARTMENTS).map((department) => ( {teams.map((team) => (
<option key={department} value={department}> <option key={team.id} value={team.id}>
{formatPersonDepartment(department, departmentCopy, fallbackCopy)} {team.name}
</option> </option>
))} ))}
</select> </select>
@@ -11,35 +11,27 @@ import {
SubmitButton, SubmitButton,
type SubmitButtonCopy, type SubmitButtonCopy,
} from "@/components/forms/submitButton" } from "@/components/forms/submitButton"
import { PERSON_DEPARTMENTS } from "@/lib/constants"
import { import {
buildUnifiedCreateSchema, buildUnifiedCreateSchema,
type UnifiedCreateFormType, type UnifiedCreateFormType,
type UnifiedSchemaCopy, type UnifiedSchemaCopy,
} from "@/schemas/user.schema" } from "@/schemas/user.schema"
import type { TeamSummary } from "@/types"
import { import type { UserFormCopy, UserRoleCopy } from "./user.copy"
formatPersonDepartment,
type PersonDepartmentCopy,
type PersonFallbackCopy,
type UserFormCopy,
type UserRoleCopy,
} from "./user.copy"
export default function NewUserForm({ export default function NewUserForm({
formCopy, formCopy,
schemaCopy, schemaCopy,
roleLabels, roleLabels,
departmentCopy,
fallbackCopy,
submitButtonCopy, submitButtonCopy,
teams,
}: { }: {
formCopy: UserFormCopy formCopy: UserFormCopy
schemaCopy: UnifiedSchemaCopy schemaCopy: UnifiedSchemaCopy
roleLabels: UserRoleCopy roleLabels: UserRoleCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
submitButtonCopy: SubmitButtonCopy submitButtonCopy: SubmitButtonCopy
teams: TeamSummary[]
}) { }) {
const router = useRouter() const router = useRouter()
const schema = useMemo( const schema = useMemo(
@@ -101,12 +93,11 @@ export default function NewUserForm({
placeholder={formCopy.lastNamePlaceholder} placeholder={formCopy.lastNamePlaceholder}
register={register("lastName")} register={register("lastName")}
/> />
<DepartmentSelect <TeamSelect
error={errors.department?.message} error={errors.teamId?.message}
formCopy={formCopy} formCopy={formCopy}
departmentCopy={departmentCopy} register={register("teamId")}
fallbackCopy={fallbackCopy} teams={teams}
register={register("department")}
/> />
<UserTextInput <UserTextInput
error={errors.email?.message} error={errors.email?.message}
@@ -210,33 +201,31 @@ function RoleSelect({
) )
} }
function DepartmentSelect({ function TeamSelect({
error, error,
formCopy, formCopy,
departmentCopy,
fallbackCopy,
register, register,
teams,
}: { }: {
error?: string error?: string
formCopy: UserFormCopy formCopy: UserFormCopy
departmentCopy: PersonDepartmentCopy
fallbackCopy: PersonFallbackCopy
register: UseFormRegisterReturn register: UseFormRegisterReturn
teams: TeamSummary[]
}) { }) {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="department" className="mb-2 block text-lg"> <label htmlFor="teamId" className="mb-2 block text-lg">
{formCopy.departmentLabel} {formCopy.teamLabel}
</label> </label>
<select <select
id="department" id="teamId"
{...register} {...register}
className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`} className={`w-full rounded-lg border px-4 py-2 ${error ? "border-error" : ""}`}
> >
<option value="">{formCopy.departmentPlaceholder}</option> <option value="">{formCopy.teamPlaceholder}</option>
{Object.keys(PERSON_DEPARTMENTS).map((department) => ( {teams.map((team) => (
<option key={department} value={department}> <option key={team.id} value={team.id}>
{formatPersonDepartment(department, departmentCopy, fallbackCopy)} {team.name}
</option> </option>
))} ))}
</select> </select>
@@ -3,20 +3,4 @@ import type { Dictionary } from "@/i18n/dictionaries"
export type PersonListCopy = Dictionary["inventory"]["people"]["list"] export type PersonListCopy = Dictionary["inventory"]["people"]["list"]
export type PersonDetailCopy = Dictionary["inventory"]["people"]["detail"] export type PersonDetailCopy = Dictionary["inventory"]["people"]["detail"]
export type PersonFormCopy = Dictionary["inventory"]["people"]["form"] export type PersonFormCopy = Dictionary["inventory"]["people"]["form"]
export type PersonDepartmentCopy =
Dictionary["inventory"]["people"]["departments"]
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"] export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
export function formatPersonDepartment(
department: string | null | undefined,
departmentCopy: PersonDepartmentCopy,
fallbackCopy: PersonFallbackCopy,
) {
if (!department) {
return fallbackCopy.unknownDepartment
}
return department in departmentCopy
? departmentCopy[department as keyof PersonDepartmentCopy]
: fallbackCopy.unknownDepartment
}
@@ -6,8 +6,6 @@ export type UserStatusCopy = Dictionary["admin"]["users"]["status"]
export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"] export type UserFallbackCopy = Dictionary["admin"]["users"]["fallback"]
export type UserResetPasswordCopy = export type UserResetPasswordCopy =
Dictionary["admin"]["users"]["resetPassword"] Dictionary["admin"]["users"]["resetPassword"]
export type PersonDepartmentCopy =
Dictionary["inventory"]["people"]["departments"]
export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"] export type PersonFallbackCopy = Dictionary["inventory"]["people"]["fallback"]
export function formatUserRole( export function formatUserRole(
@@ -19,17 +17,3 @@ export function formatUserRole(
? roleCopy[role as keyof UserRoleCopy] ? roleCopy[role as keyof UserRoleCopy]
: fallbackCopy.unknownRole : fallbackCopy.unknownRole
} }
export function formatPersonDepartment(
department: string | null | undefined,
departmentCopy: PersonDepartmentCopy,
fallbackCopy: PersonFallbackCopy,
): string {
if (!department) {
return fallbackCopy.unknownDepartment
}
return department in departmentCopy
? departmentCopy[department as keyof PersonDepartmentCopy]
: fallbackCopy.unknownDepartment
}
+3 -2
View File
@@ -1,10 +1,12 @@
import { getI18n } from "@/i18n/server" import { getI18n } from "@/i18n/server"
import { listTeamsUseCase } from "@/use-cases/team.use-cases"
import NewPersonForm from "../_components/new.person.form" import NewPersonForm from "../_components/new.person.form"
export default async function NewUserPage() { export default async function NewUserPage() {
const { dictionary } = await getI18n() const { dictionary } = await getI18n()
const copy = dictionary.admin.users const copy = dictionary.admin.users
const teams = await listTeamsUseCase()
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -15,9 +17,8 @@ export default async function NewUserPage() {
formCopy={copy.form} formCopy={copy.form}
schemaCopy={{ ...copy.schema, ...dictionary.inventory.people.schema }} schemaCopy={{ ...copy.schema, ...dictionary.inventory.people.schema }}
roleLabels={copy.roles} roleLabels={copy.roles}
departmentCopy={dictionary.inventory.people.departments}
fallbackCopy={dictionary.inventory.people.fallback}
submitButtonCopy={dictionary.common.submitButton} submitButtonCopy={dictionary.common.submitButton}
teams={teams}
/> />
</div> </div>
) )
+3 -13
View File
@@ -8,11 +8,6 @@ import { UserStatus } from "@/generated/prisma/client"
import { getI18n } from "@/i18n/server" import { getI18n } from "@/i18n/server"
import { PersonService } from "@/services/person.service" import { PersonService } from "@/services/person.service"
import {
formatPersonDepartment,
type PersonDepartmentCopy,
type PersonFallbackCopy,
} from "./_components/person.copy"
import TeamsTab from "./_components/teams.tab" import TeamsTab from "./_components/teams.tab"
import { import {
formatUserRole, formatUserRole,
@@ -55,8 +50,7 @@ export default async function PeoplePage(props: {
const userStatusCopy = userCopy.status const userStatusCopy = userCopy.status
const userRoleLabels = userCopy.roles as UserRoleCopy const userRoleLabels = userCopy.roles as UserRoleCopy
const userFallbackCopy = userCopy.fallback as UserFallbackCopy const userFallbackCopy = userCopy.fallback as UserFallbackCopy
const departmentCopy = copy.departments as PersonDepartmentCopy const personFallbackCopy = copy.fallback
const personFallbackCopy = copy.fallback as PersonFallbackCopy
const peopleList = ( const peopleList = (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -83,7 +77,7 @@ export default async function PeoplePage(props: {
{copy.list.columns.phone} {copy.list.columns.phone}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
{copy.list.columns.department} {copy.list.columns.team}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
{copy.list.columns.role} {copy.list.columns.role}
@@ -105,11 +99,7 @@ export default async function PeoplePage(props: {
<td className="p-4">{person.email}</td> <td className="p-4">{person.email}</td>
<td className="p-4">{person.phone}</td> <td className="p-4">{person.phone}</td>
<td className="p-4"> <td className="p-4">
{formatPersonDepartment( {person.team?.name ?? personFallbackCopy.noTeam}
person.department,
departmentCopy,
personFallbackCopy,
)}
</td> </td>
<td className="p-4"> <td className="p-4">
{person.user {person.user
+9 -18
View File
@@ -431,7 +431,7 @@ export const en = {
name: "Name", name: "Name",
email: "Email", email: "Email",
phone: "Phone", phone: "Phone",
department: "Department", team: "Team",
role: "Role", role: "Role",
status: "Status", status: "Status",
actions: "Actions", actions: "Actions",
@@ -446,7 +446,7 @@ export const en = {
labels: { labels: {
email: "Email", email: "Email",
phone: "Phone", phone: "Phone",
department: "Department", team: "Team",
role: "Role", role: "Role",
status: "Status", status: "Status",
noUser: "No user account", noUser: "No user account",
@@ -464,8 +464,8 @@ export const en = {
firstNamePlaceholder: "First name", firstNamePlaceholder: "First name",
lastNameLabel: "Last Name", lastNameLabel: "Last Name",
lastNamePlaceholder: "Last name", lastNamePlaceholder: "Last name",
departmentLabel: "Department", teamLabel: "Team",
departmentPlaceholder: "Select a department", teamPlaceholder: "Select a team",
emailLabel: "Email", emailLabel: "Email",
emailPlaceholder: "Email", emailPlaceholder: "Email",
phoneLabel: "Phone", phoneLabel: "Phone",
@@ -479,19 +479,9 @@ export const en = {
updateSubmit: "Update Person", updateSubmit: "Update Person",
}, },
fallback: { fallback: {
unknownDepartment: "Unknown department", noTeam: "",
unknownStatus: "Unknown status", unknownStatus: "Unknown status",
}, },
departments: {
IT: "IT",
ENGINEERING: "Engineering",
LOGISTICS: "Logistics",
TRAFFIC: "Traffic",
DRIVER: "Driver",
ADMINISTRATION: "Administration",
SALES: "Sales",
OTHER: "Other",
},
actions: { actions: {
createSuccess: "Person created successfully", createSuccess: "Person created successfully",
createFailure: "Failed to create person", createFailure: "Failed to create person",
@@ -499,14 +489,15 @@ export const en = {
updateFailure: "Failed to update person", updateFailure: "Failed to update person",
duplicateEmail: "Email already exists", duplicateEmail: "Email already exists",
notFound: "Person not found", notFound: "Person not found",
teamNotFound: "Team not found",
}, },
schema: { schema: {
firstNameRequired: "First name is required", firstNameRequired: "First name is required",
lastNameRequired: "Last name is required", lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Email format is invalid", emailInvalid: "Email format is invalid",
idRequired: "ID is required", idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID", userIdInvalid: "User ID must be a valid UUID",
teamIdInvalid: "Team must be a valid id",
}, },
}, },
movements: { movements: {
@@ -573,8 +564,8 @@ export const en = {
firstNamePlaceholder: "First name", firstNamePlaceholder: "First name",
lastNameLabel: "Last Name", lastNameLabel: "Last Name",
lastNamePlaceholder: "Last name", lastNamePlaceholder: "Last name",
departmentLabel: "Department", teamLabel: "Team",
departmentPlaceholder: "Select a department", teamPlaceholder: "Select a team",
emailLabel: "Email", emailLabel: "Email",
emailPlaceholder: "user@example.com", emailPlaceholder: "user@example.com",
phoneLabel: "Phone", phoneLabel: "Phone",
+9 -18
View File
@@ -437,7 +437,7 @@ export const es = {
name: "Nombre", name: "Nombre",
email: "Correo electrónico", email: "Correo electrónico",
phone: "Teléfono", phone: "Teléfono",
department: "Departamento", team: "Equipo",
role: "Rol", role: "Rol",
status: "Estado", status: "Estado",
actions: "Acciones", actions: "Acciones",
@@ -452,7 +452,7 @@ export const es = {
labels: { labels: {
email: "Correo electrónico", email: "Correo electrónico",
phone: "Teléfono", phone: "Teléfono",
department: "Departamento", team: "Equipo",
role: "Rol", role: "Rol",
status: "Estado", status: "Estado",
noUser: "Sin cuenta de usuario", noUser: "Sin cuenta de usuario",
@@ -470,8 +470,8 @@ export const es = {
firstNamePlaceholder: "Nombre", firstNamePlaceholder: "Nombre",
lastNameLabel: "Apellido", lastNameLabel: "Apellido",
lastNamePlaceholder: "Apellido", lastNamePlaceholder: "Apellido",
departmentLabel: "Departamento", teamLabel: "Equipo",
departmentPlaceholder: "Selecciona un departamento", teamPlaceholder: "Selecciona un equipo",
emailLabel: "Correo electrónico", emailLabel: "Correo electrónico",
emailPlaceholder: "Correo electrónico", emailPlaceholder: "Correo electrónico",
phoneLabel: "Teléfono", phoneLabel: "Teléfono",
@@ -486,19 +486,9 @@ export const es = {
updateSubmit: "Actualizar persona", updateSubmit: "Actualizar persona",
}, },
fallback: { fallback: {
unknownDepartment: "Departamento desconocido", noTeam: "",
unknownStatus: "Estado desconocido", unknownStatus: "Estado desconocido",
}, },
departments: {
IT: "IT",
ENGINEERING: "Ingeniería",
LOGISTICS: "Logística",
TRAFFIC: "Tráfico",
DRIVER: "Chofer",
ADMINISTRATION: "Administración",
SALES: "Ventas",
OTHER: "Otro",
},
actions: { actions: {
createSuccess: "Persona creada correctamente", createSuccess: "Persona creada correctamente",
createFailure: "Error al crear la persona", createFailure: "Error al crear la persona",
@@ -506,14 +496,15 @@ export const es = {
updateFailure: "Error al actualizar la persona", updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe", duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada", notFound: "Persona no encontrada",
teamNotFound: "Equipo no encontrado",
}, },
schema: { schema: {
firstNameRequired: "El nombre es obligatorio", firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio", lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "El correo electrónico no es válido", emailInvalid: "El correo electrónico no es válido",
idRequired: "El ID es obligatorio", idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido", userIdInvalid: "El ID de usuario debe ser un UUID válido",
teamIdInvalid: "El equipo debe ser un id válido",
}, },
}, },
movements: { movements: {
@@ -580,8 +571,8 @@ export const es = {
firstNamePlaceholder: "Nombre", firstNamePlaceholder: "Nombre",
lastNameLabel: "Apellido", lastNameLabel: "Apellido",
lastNamePlaceholder: "Apellido", lastNamePlaceholder: "Apellido",
departmentLabel: "Departamento", teamLabel: "Equipo",
departmentPlaceholder: "Selecciona un departamento", teamPlaceholder: "Selecciona un equipo",
emailLabel: "Correo electrónico", emailLabel: "Correo electrónico",
emailPlaceholder: "usuario@ejemplo.com", emailPlaceholder: "usuario@ejemplo.com",
phoneLabel: "Teléfono", phoneLabel: "Teléfono",
+3 -1
View File
@@ -39,7 +39,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
if (!success) throw new Error("Invalid email or password") if (!success) throw new Error("Invalid email or password")
const user = await getUserCredentialsByEmail(normalizeEmail(data.email)) const user = await getUserCredentialsByEmail(
normalizeEmail(data.email),
)
if (!user) { if (!user) {
throw new Error("Invalid email or password") throw new Error("Invalid email or password")
-11
View File
@@ -8,17 +8,6 @@ export const SIGN_IN_URL = "/login"
export const TOKEN_EXPIRATION_SECONDS = 60 * 60 * 2 // 2 hour export const TOKEN_EXPIRATION_SECONDS = 60 * 60 * 2 // 2 hour
export const PERSON_DEPARTMENTS = {
IT: "IT",
ENGINEERING: "ENGINEERING",
LOGISTICS: "LOGISTICS",
TRAFFIC: "TRAFFIC",
DRIVER: "DRIVER",
ADMINISTRATION: "ADMINISTRATION",
SALES: "SALES",
OTHER: "OTHER",
} as const
export const ITEM_STATUS = { export const ITEM_STATUS = {
AVAILABLE: "AVAILABLE", AVAILABLE: "AVAILABLE",
ASSIGNED: "ASSIGNED", ASSIGNED: "ASSIGNED",
+16 -12
View File
@@ -44,12 +44,14 @@ function buildTrackingTypeSchema(copy: ItemSchemaCopy) {
} }
function buildOptionalTrackingTypeSchema(copy: ItemSchemaCopy) { function buildOptionalTrackingTypeSchema(copy: ItemSchemaCopy) {
return z.preprocess( return z
(value) => (value === "" || value === null ? undefined : value), .preprocess(
z.enum(itemTrackingTypes, { (value) => (value === "" || value === null ? undefined : value),
error: () => copy.invalidTrackingType, z.enum(itemTrackingTypes, {
}), error: () => copy.invalidTrackingType,
).optional() }),
)
.optional()
} }
function buildStatusSchema(copy: ItemSchemaCopy) { function buildStatusSchema(copy: ItemSchemaCopy) {
@@ -65,12 +67,14 @@ function buildStatusSchema(copy: ItemSchemaCopy) {
} }
function buildOptionalStatusSchema(copy: ItemSchemaCopy) { function buildOptionalStatusSchema(copy: ItemSchemaCopy) {
return z.preprocess( return z
(value) => (value === "" || value === null ? undefined : value), .preprocess(
z.enum(itemStatuses, { (value) => (value === "" || value === null ? undefined : value),
error: () => copy.invalidStatus, z.enum(itemStatuses, {
}), error: () => copy.invalidStatus,
).optional() }),
)
.optional()
} }
function buildOptionalReasonSchema() { function buildOptionalReasonSchema() {
+6 -15
View File
@@ -7,23 +7,12 @@ export type PersonSchemaCopy = Dictionary["inventory"]["people"]["schema"]
const defaultPersonSchemaCopy: PersonSchemaCopy = { const defaultPersonSchemaCopy: PersonSchemaCopy = {
firstNameRequired: "First name is required", firstNameRequired: "First name is required",
lastNameRequired: "Last name is required", lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Email format is invalid", emailInvalid: "Email format is invalid",
idRequired: "ID is required", idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID", userIdInvalid: "User ID must be a valid UUID",
teamIdInvalid: "Team must be a valid id",
} }
export const personDepartments = [
"IT",
"ENGINEERING",
"TRAFFIC",
"DRIVER",
"LOGISTICS",
"ADMINISTRATION",
"SALES",
"OTHER",
] as const
function buildPersonBaseSchema(copy: PersonSchemaCopy) { function buildPersonBaseSchema(copy: PersonSchemaCopy) {
return z.object({ return z.object({
id: z.string().optional(), id: z.string().optional(),
@@ -33,9 +22,11 @@ function buildPersonBaseSchema(copy: PersonSchemaCopy) {
lastName: z.string().min(1, { lastName: z.string().min(1, {
error: copy.lastNameRequired, error: copy.lastNameRequired,
}), }),
department: z.enum(personDepartments, { teamId: z
error: copy.departmentRequired, .union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
}), .transform((val) => (val === "" ? null : val))
.nullable()
.optional(),
email: z.string().optional().nullable(), email: z.string().optional().nullable(),
phone: z.string().optional().nullable(), phone: z.string().optional().nullable(),
userId: z userId: z
+10 -7
View File
@@ -1,7 +1,6 @@
import { z } from "zod" import { z } from "zod"
import type { Dictionary } from "@/i18n/dictionaries" import type { Dictionary } from "@/i18n/dictionaries"
import { personDepartments } from "@/schemas/person.schema"
export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"] export type UserSchemaCopy = Dictionary["admin"]["users"]["schema"]
@@ -93,9 +92,11 @@ export function buildUnifiedUpdateSchema(copy: UnifiedSchemaCopy) {
id: z.string().nonempty(copy.idRequired), id: z.string().nonempty(copy.idRequired),
firstName: z.string().trim().min(1, { error: copy.firstNameRequired }), firstName: z.string().trim().min(1, { error: copy.firstNameRequired }),
lastName: z.string().trim().min(1, { error: copy.lastNameRequired }), lastName: z.string().trim().min(1, { error: copy.lastNameRequired }),
department: z.enum(personDepartments, { teamId: z
error: copy.departmentRequired, .union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
}), .transform((val) => (val === "" ? null : val))
.nullable()
.optional(),
email: z email: z
.union([z.email({ error: copy.emailInvalid }), z.literal(""), z.null()]) .union([z.email({ error: copy.emailInvalid }), z.literal(""), z.null()])
.optional(), .optional(),
@@ -129,9 +130,11 @@ export function buildUnifiedCreateSchema(copy: UnifiedSchemaCopy) {
.object({ .object({
firstName: z.string().trim().min(1, { error: copy.firstNameRequired }), firstName: z.string().trim().min(1, { error: copy.firstNameRequired }),
lastName: z.string().trim().min(1, { error: copy.lastNameRequired }), lastName: z.string().trim().min(1, { error: copy.lastNameRequired }),
department: z.enum(personDepartments, { teamId: z
error: copy.departmentRequired, .union([z.string().uuid({ error: copy.teamIdInvalid }), z.literal("")])
}), .transform((val) => (val === "" ? null : val))
.nullable()
.optional(),
email: z.email({ error: copy.emailInvalid }), email: z.email({ error: copy.emailInvalid }),
phone: z.string().optional().nullable(), phone: z.string().optional().nullable(),
role: unifiedFormRoleSchema, role: unifiedFormRoleSchema,
+5 -1
View File
@@ -1,4 +1,8 @@
import type { AssetStatus, InventoryMovementType, Prisma } from "@/generated/prisma/client" import type {
AssetStatus,
InventoryMovementType,
Prisma,
} from "@/generated/prisma/client"
import { paginate } from "@/lib/paginate" import { paginate } from "@/lib/paginate"
import prisma from "@/lib/prisma" import prisma from "@/lib/prisma"
import type { CreateMovementFormType } from "@/schemas/movement.schema" import type { CreateMovementFormType } from "@/schemas/movement.schema"
+6
View File
@@ -4,6 +4,12 @@ import prisma from "@/lib/prisma"
const personWithUserSelect = { const personWithUserSelect = {
include: { include: {
team: {
select: {
id: true,
name: true,
},
},
user: { user: {
select: { select: {
id: true, id: true,
+1 -1
View File
@@ -1,7 +1,7 @@
import type { import type {
Prisma,
Asset as PrismaAsset, Asset as PrismaAsset,
AssetStatus as PrismaAssetStatus, AssetStatus as PrismaAssetStatus,
Prisma,
} from "@/generated/prisma/client" } from "@/generated/prisma/client"
import type { Assignment } from "./assignment" import type { Assignment } from "./assignment"
+3 -4
View File
@@ -9,8 +9,8 @@ type FieldErrors = Record<string, string[]>
type CreateItemUseCaseInput = Omit<CreateItemData, "trackingType" | "status"> & type CreateItemUseCaseInput = Omit<CreateItemData, "trackingType" | "status"> &
Partial<Pick<CreateItemData, "trackingType" | "status">> & { Partial<Pick<CreateItemData, "trackingType" | "status">> & {
actorId: string actorId: string
} }
type UpdateItemUseCaseInput = UpdateItemData & { type UpdateItemUseCaseInput = UpdateItemData & {
actorId: string actorId: string
@@ -145,8 +145,7 @@ export async function updateItemUseCase(
return itemError({ name: ["An item with this name already exists"] }) return itemError({ name: ["An item with this name already exists"] })
} }
const effectiveTrackingType = const effectiveTrackingType = trackingType ?? existingItem.trackingType
trackingType ?? existingItem.trackingType
const isSerialized = effectiveTrackingType === "SERIALIZED" const isSerialized = effectiveTrackingType === "SERIALIZED"
await ItemService.update( await ItemService.update(
+88 -15
View File
@@ -11,6 +11,7 @@ import type {
UnifiedUpdateFormType, UnifiedUpdateFormType,
} from "@/schemas/user.schema" } from "@/schemas/user.schema"
import { PersonService } from "@/services/person.service" import { PersonService } from "@/services/person.service"
import { TeamService } from "@/services/team.service"
import { getUserByEmail } from "@/services/user.service" import { getUserByEmail } from "@/services/user.service"
type FieldErrors = Record<string, string[]> type FieldErrors = Record<string, string[]>
@@ -48,10 +49,70 @@ function uniqueErrorFor(error: unknown): FieldErrors | null {
return { email: ["Email already exists"] } return { email: ["Email already exists"] }
} }
function foreignKeyErrorFor(error: unknown): FieldErrors | null {
if (
!(error instanceof Prisma.PrismaClientKnownRequestError) ||
error.code !== "P2003"
) {
return null
}
const fieldName = error.meta?.field_name
if (fieldName === "Person_teamId_fkey" || fieldName === "teamId") {
return { teamId: ["Team not found"] }
}
return null
}
function errorFor(error: unknown): FieldErrors | null {
return uniqueErrorFor(error) ?? foreignKeyErrorFor(error)
}
function teamRelationInputForCreate(teamId: string | null | undefined) {
if (teamId) {
return { team: { connect: { id: teamId } } }
}
return {}
}
function teamRelationInputForUpdate(teamId: string | null | undefined) {
if (teamId) {
return { team: { connect: { id: teamId } } }
}
return { team: { disconnect: true } }
}
function userRelationInputForCreate(userId: string | null | undefined) {
if (userId) {
return { user: { connect: { id: userId } } }
}
return {}
}
async function validateTeamId(
teamId: string | null | undefined,
tx: Prisma.TransactionClient,
): Promise<PersonUseCaseResult | null> {
if (!teamId) return null
const team = await TeamService.findById(teamId, tx)
if (!team) {
return personError({ teamId: ["Team not found"] })
}
return null
}
export async function createPersonUseCase( export async function createPersonUseCase(
input: CreatePersonFormType, input: CreatePersonFormType,
): Promise<PersonUseCaseResult> { ): Promise<PersonUseCaseResult> {
const { firstName, lastName, department, email, phone, userId } = input const { firstName, lastName, teamId, email, phone, userId } = input
try { try {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
@@ -63,14 +124,17 @@ export async function createPersonUseCase(
} }
} }
const teamError = await validateTeamId(teamId, tx)
if (teamError) return teamError
await PersonService.create( await PersonService.create(
{ {
firstName, firstName,
lastName, lastName,
department, ...teamRelationInputForCreate(teamId),
email: email || null, email: email || null,
phone: phone || null, phone: phone || null,
...(userId ? { user: { connect: { id: userId } } } : {}), ...userRelationInputForCreate(userId),
}, },
tx, tx,
) )
@@ -80,7 +144,7 @@ export async function createPersonUseCase(
} }
}) })
} catch (error) { } catch (error) {
const errors = uniqueErrorFor(error) const errors = errorFor(error)
if (errors) { if (errors) {
return personError(errors) return personError(errors)
@@ -93,7 +157,7 @@ export async function createPersonUseCase(
export async function updatePersonUseCase( export async function updatePersonUseCase(
input: UpdatePersonFormType, input: UpdatePersonFormType,
): Promise<PersonUseCaseResult> { ): Promise<PersonUseCaseResult> {
const { id, firstName, lastName, department, email, phone, userId } = input const { id, firstName, lastName, teamId, email, phone, userId } = input
try { try {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
@@ -105,17 +169,20 @@ export async function updatePersonUseCase(
} }
} }
const teamError = await validateTeamId(teamId, tx)
if (teamError) return teamError
await PersonService.update( await PersonService.update(
id, id,
{ {
firstName, firstName,
lastName, lastName,
department, ...teamRelationInputForUpdate(teamId),
email: email || null, email: email || null,
phone: phone || null, phone: phone || null,
...(userId ...(userId
? { user: { connect: { id: userId } } } ? { user: { connect: { id: userId } } }
: { userId: null }), : { user: { disconnect: true } }),
}, },
tx, tx,
) )
@@ -125,7 +192,7 @@ export async function updatePersonUseCase(
} }
}) })
} catch (error) { } catch (error) {
const errors = uniqueErrorFor(error) const errors = errorFor(error)
if (errors) { if (errors) {
return personError(errors) return personError(errors)
@@ -141,7 +208,7 @@ export async function createPersonUserUseCase(
const { const {
firstName, firstName,
lastName, lastName,
department, teamId,
email, email,
phone, phone,
role, role,
@@ -162,13 +229,16 @@ export async function createPersonUserUseCase(
return personError({ email: ["Email already exists"] }) return personError({ email: ["Email already exists"] })
} }
const teamError = await validateTeamId(teamId, tx)
if (teamError) return teamError
if (role === "NO_USER") { if (role === "NO_USER") {
// Person-only creation — no User record // Person-only creation — no User record
await PersonService.create( await PersonService.create(
{ {
firstName, firstName,
lastName, lastName,
department, ...teamRelationInputForCreate(teamId),
email, email,
phone: phone ?? null, phone: phone ?? null,
}, },
@@ -187,7 +257,7 @@ export async function createPersonUserUseCase(
{ {
firstName, firstName,
lastName, lastName,
department, ...teamRelationInputForCreate(teamId),
email, email,
phone: phone ?? null, phone: phone ?? null,
}, },
@@ -221,7 +291,7 @@ export async function createPersonUserUseCase(
return { success: true } return { success: true }
}) })
} catch (error) { } catch (error) {
const errors = uniqueErrorFor(error) const errors = errorFor(error)
if (errors) { if (errors) {
return personError(errors) return personError(errors)
@@ -238,7 +308,7 @@ export async function updatePersonUserUseCase(
id, id,
firstName, firstName,
lastName, lastName,
department, teamId,
email, email,
phone, phone,
role, role,
@@ -261,12 +331,15 @@ export async function updatePersonUserUseCase(
} }
} }
const teamError = await validateTeamId(teamId, tx)
if (teamError) return teamError
await PersonService.update( await PersonService.update(
id, id,
{ {
firstName, firstName,
lastName, lastName,
department, ...teamRelationInputForUpdate(teamId),
email: email || null, email: email || null,
phone: phone || null, phone: phone || null,
}, },
@@ -302,7 +375,7 @@ export async function updatePersonUserUseCase(
return { success: true } return { success: true }
}) })
} catch (error) { } catch (error) {
const errors = uniqueErrorFor(error) const errors = errorFor(error)
if (errors) { if (errors) {
return personError(errors) return personError(errors)
+1 -1
View File
@@ -27,7 +27,7 @@ async function createPerson(page: Page, name: string, email: string) {
await page.goto("/people/new") await page.goto("/people/new")
await page.getByLabel("Nombre").fill(name) await page.getByLabel("Nombre").fill(name)
await page.getByLabel("Apellido").fill("E2E") await page.getByLabel("Apellido").fill("E2E")
await page.getByLabel("Departamento").selectOption("OTHER") await page.getByLabel("Equipo").selectOption({ label: "Other" })
await page.getByLabel("Correo electrónico").fill(email) await page.getByLabel("Correo electrónico").fill(email)
await page.getByLabel("Teléfono").fill("123456789") await page.getByLabel("Teléfono").fill("123456789")
await page.getByLabel("Rol").selectOption("NO_USER") await page.getByLabel("Rol").selectOption("NO_USER")
+142
View File
@@ -0,0 +1,142 @@
import { expect, type Page, test } from "@playwright/test"
async function setLocaleCookie(
page: Page,
locale: "en" | "es",
baseURL?: string,
) {
await page.context().addCookies([
{
name: "stock-manager-locale",
value: locale,
url: baseURL ?? "http://127.0.0.1:3100",
},
])
}
async function signInAsAdmin(page: Page, baseURL?: string) {
await setLocaleCookie(page, "en", baseURL)
await page.goto("/login")
await page.getByLabel("Email").fill("admin@example.test")
await page.getByLabel("Password").fill("admin-password")
await page.getByRole("button", { name: "Sign In" }).click()
await expect(page).toHaveURL("/")
}
async function createTeam(page: Page, name: string) {
await page.goto("/people?tab=teams")
await page.getByLabel("Team name").fill(name)
await page.getByRole("button", { name: "Create Team" }).click()
await expect(page.getByText("Team created successfully")).toBeVisible()
}
test.describe("people and teams", () => {
test("switches between people and teams tabs via URL", async ({
baseURL,
page,
}) => {
await signInAsAdmin(page, baseURL)
await page.goto("/people")
const sections = page.getByRole("navigation", { name: "People sections" })
await expect(sections).toBeVisible()
await page.goto("/people?tab=people")
await expect(
sections.getByRole("link", { name: "People" }),
).toHaveAttribute("aria-current", "page")
await page.goto("/people?tab=teams")
await expect(sections.getByRole("link", { name: "Teams" })).toHaveAttribute(
"aria-current",
"page",
)
await expect(page.getByLabel("Team name")).toBeVisible()
await page.goto("/people?tab=invalid")
await expect(
sections.getByRole("link", { name: "People" }),
).toHaveAttribute("aria-current", "page")
})
test("creates, renames, and deletes a team", async ({ baseURL, page }) => {
const timestamp = Date.now()
const originalName = `E2E Team ${timestamp}`
const updatedName = `E2E Team Updated ${timestamp}`
await signInAsAdmin(page, baseURL)
await createTeam(page, originalName)
const row = page.getByRole("row", { name: new RegExp(originalName) })
await expect(row).toBeVisible()
await row.getByRole("button", { name: "Edit team" }).click()
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await dialog.getByLabel("Team name").fill(updatedName)
await dialog.getByRole("button", { name: "Update Team" }).click()
await expect(dialog).not.toBeVisible()
await expect(page.getByText("Team updated successfully")).toBeVisible()
await expect(page.getByRole("row", { name: updatedName })).toBeVisible()
await page
.getByRole("row", { name: updatedName })
.getByRole("button", { name: "Delete team" })
.click()
await expect(page.getByText("Team deleted successfully")).toBeVisible()
await expect(page.getByRole("row", { name: updatedName })).not.toBeVisible()
})
test("creates a person with a team and shows the team in the list", async ({
baseURL,
page,
}) => {
const timestamp = Date.now()
const teamName = `E2E Person Team ${timestamp}`
const personName = `E2E Person ${timestamp}`
await signInAsAdmin(page, baseURL)
await createTeam(page, teamName)
await page.goto("/people/new")
await page.getByLabel("First Name").fill(personName)
await page.getByLabel("Last Name").fill("E2E")
await page.getByLabel("Team").selectOption({ label: teamName })
await page.getByLabel("Email").fill(`e2e-${timestamp}@example.test`)
await page.getByLabel("Phone").fill("123456789")
await page.getByLabel("Role").selectOption("NO_USER")
await page.getByRole("button", { name: "Create User" }).click()
await expect(page.getByText("User created successfully")).toBeVisible()
await page.goto("/people?tab=people")
const row = page.getByRole("row", { name: new RegExp(personName) })
await expect(row).toContainText(teamName)
await row.getByRole("link", { name: "View person" }).click()
await expect(page.getByText(teamName)).toBeVisible()
})
test("shows no team fallback for a person without a team", async ({
baseURL,
page,
}) => {
const timestamp = Date.now()
const personName = `E2E No Team ${timestamp}`
await signInAsAdmin(page, baseURL)
await page.goto("/people/new")
await page.getByLabel("First Name").fill(personName)
await page.getByLabel("Last Name").fill("E2E")
await page.getByLabel("Team").selectOption({ value: "" })
await page.getByLabel("Email").fill(`e2e-noteam-${timestamp}@example.test`)
await page.getByLabel("Phone").fill("123456789")
await page.getByLabel("Role").selectOption("NO_USER")
await page.getByRole("button", { name: "Create User" }).click()
await expect(page.getByText("User created successfully")).toBeVisible()
await page.goto("/people?tab=people")
const row = page.getByRole("row", { name: new RegExp(personName) })
await expect(row).toContainText("—")
})
})
+16 -20
View File
@@ -1,8 +1,4 @@
import type { import type { PrismaClient, UserRole } from "@/generated/prisma/client"
PersonDepartment,
PrismaClient,
UserRole,
} from "@/generated/prisma/client"
import { UserStatus } from "@/generated/prisma/client" import { UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email" import { normalizeEmail } from "@/lib/email"
@@ -54,12 +50,25 @@ export async function createTestCategory(
}) })
} }
export async function createTestTeam(
prisma: PrismaClient,
overrides: Partial<{ name: string }> = {},
) {
const suffix = nextSuffix()
return prisma.team.create({
data: {
name: overrides.name ?? `Test Team ${suffix}`,
},
})
}
export async function createTestPerson( export async function createTestPerson(
prisma: PrismaClient, prisma: PrismaClient,
overrides: Partial<{ overrides: Partial<{
firstName: string firstName: string
lastName: string lastName: string
department: PersonDepartment teamId: string | null
email: string | null email: string | null
phone: string | null phone: string | null
}> = {}, }> = {},
@@ -70,26 +79,13 @@ export async function createTestPerson(
data: { data: {
firstName: overrides.firstName ?? "Test", firstName: overrides.firstName ?? "Test",
lastName: overrides.lastName ?? `Person-${suffix}`, lastName: overrides.lastName ?? `Person-${suffix}`,
department: overrides.department ?? "OTHER", teamId: overrides.teamId ?? null,
email: overrides.email ?? null, email: overrides.email ?? null,
phone: overrides.phone ?? null, phone: overrides.phone ?? null,
}, },
}) })
} }
export async function createTestTeam(
prisma: PrismaClient,
overrides: Partial<{ name: string }> = {},
) {
const suffix = nextSuffix()
return prisma.team.create({
data: {
name: overrides.name ?? `Test Team ${suffix}`,
},
})
}
export async function createTestItem( export async function createTestItem(
prisma: PrismaClient, prisma: PrismaClient,
overrides: Partial<{ overrides: Partial<{
@@ -1,7 +1,11 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client" import type { PrismaClient } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email" import { normalizeEmail } from "@/lib/email"
import { createTestPerson, createTestUser } from "../helpers/factories" import {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import { import {
resetIntegrationTestDatabase, resetIntegrationTestDatabase,
startIntegrationTestDatabase, startIntegrationTestDatabase,
@@ -33,10 +37,12 @@ afterAll(async () => {
describe("createPersonUserUseCase", () => { describe("createPersonUserUseCase", () => {
describe("NO_USER role (person-only creation)", () => { describe("NO_USER role (person-only creation)", () => {
it("creates a Person without a User record when role is NO_USER", async () => { it("creates a Person without a User record when role is NO_USER", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({ const result = await createPersonUserUseCase({
firstName: "John", firstName: "John",
lastName: "Doe", lastName: "Doe",
department: "IT", teamId: team.id,
email: "john@example.test", email: "john@example.test",
phone: null, phone: null,
role: "NO_USER", role: "NO_USER",
@@ -51,7 +57,7 @@ describe("createPersonUserUseCase", () => {
expect(person).toMatchObject({ expect(person).toMatchObject({
firstName: "John", firstName: "John",
lastName: "Doe", lastName: "Doe",
department: "IT", teamId: team.id,
email: "john@example.test", email: "john@example.test",
phone: null, phone: null,
userId: null, userId: null,
@@ -66,10 +72,12 @@ describe("createPersonUserUseCase", () => {
}) })
it("creates a Person with null email when not providing email and role is NO_USER", async () => { it("creates a Person with null email when not providing email and role is NO_USER", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({ const result = await createPersonUserUseCase({
firstName: "Jane", firstName: "Jane",
lastName: "Smith", lastName: "Smith",
department: "ENGINEERING", teamId: team.id,
email: "jane-noemail@example.test", email: "jane-noemail@example.test",
phone: "555-1234", phone: "555-1234",
role: "NO_USER", role: "NO_USER",
@@ -87,10 +95,12 @@ describe("createPersonUserUseCase", () => {
describe("real role (person + user creation)", () => { describe("real role (person + user creation)", () => {
it("creates Person and User with linked userId when role is ADMIN", async () => { it("creates Person and User with linked userId when role is ADMIN", async () => {
const team = await createTestTeam(prisma)
const result = await createPersonUserUseCase({ const result = await createPersonUserUseCase({
firstName: "Admin", firstName: "Admin",
lastName: "User", lastName: "User",
department: "IT", teamId: team.id,
email: "admin@example.test", email: "admin@example.test",
phone: null, phone: null,
role: "ADMIN", role: "ADMIN",
@@ -101,12 +111,12 @@ describe("createPersonUserUseCase", () => {
expect(result).toEqual({ success: true }) expect(result).toEqual({ success: true })
const person = await prisma.person.findFirstOrThrow({ const person = await prisma.person.findFirstOrThrow({
where: { firstName: "Admin", lastName: "User" }, where: { firstName: "Admin" },
}) })
expect(person).toMatchObject({ expect(person).toMatchObject({
firstName: "Admin", firstName: "Admin",
lastName: "User", lastName: "User",
department: "IT", teamId: team.id,
email: "admin@example.test", email: "admin@example.test",
}) })
@@ -128,6 +138,7 @@ describe("createPersonUserUseCase", () => {
}) })
it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => { it("creates Person and User for all real roles (MANAGER, STAFF, VIEWER)", async () => {
const team = await createTestTeam(prisma)
const roles = ["MANAGER", "STAFF", "VIEWER"] as const const roles = ["MANAGER", "STAFF", "VIEWER"] as const
for (const role of roles) { for (const role of roles) {
@@ -135,7 +146,7 @@ describe("createPersonUserUseCase", () => {
const result = await createPersonUserUseCase({ const result = await createPersonUserUseCase({
firstName: "Person", firstName: "Person",
lastName: suffix, lastName: suffix,
department: "IT", teamId: team.id,
email: `${suffix}@example.test`, email: `${suffix}@example.test`,
phone: null, phone: null,
role, role,
@@ -160,10 +171,12 @@ describe("createPersonUserUseCase", () => {
}) })
it("derives User.name from firstName + lastName", async () => { it("derives User.name from firstName + lastName", async () => {
const team = await createTestTeam(prisma)
await createPersonUserUseCase({ await createPersonUserUseCase({
firstName: "Maria", firstName: "Maria",
lastName: "Garcia", lastName: "Garcia",
department: "SALES", teamId: team.id,
email: "maria@example.test", email: "maria@example.test",
phone: null, phone: null,
role: "STAFF", role: "STAFF",
@@ -178,10 +191,12 @@ describe("createPersonUserUseCase", () => {
}) })
it("hashes the password when creating a User", async () => { it("hashes the password when creating a User", async () => {
const team = await createTestTeam(prisma)
await createPersonUserUseCase({ await createPersonUserUseCase({
firstName: "Hash", firstName: "Hash",
lastName: "Test", lastName: "Test",
department: "IT", teamId: team.id,
email: "hash-test@example.test", email: "hash-test@example.test",
phone: null, phone: null,
role: "STAFF", role: "STAFF",
@@ -196,20 +211,21 @@ describe("createPersonUserUseCase", () => {
if (!user.passwordHash) throw new Error("Expected password hash") if (!user.passwordHash) throw new Error("Expected password hash")
const { verifyPassword } = await import("@/lib/security") const { verifyPassword } = await import("@/lib/security")
expect(await verifyPassword("plaintext-password", user.passwordHash)).toBe( expect(
true, await verifyPassword("plaintext-password", user.passwordHash),
) ).toBe(true)
}) })
}) })
describe("cross-table email uniqueness", () => { describe("cross-table email uniqueness", () => {
it("rejects submission when email already exists in Person table", async () => { it("rejects submission when email already exists in Person table", async () => {
const team = await createTestTeam(prisma)
await createTestPerson(prisma, { email: "existing-person@example.test" }) await createTestPerson(prisma, { email: "existing-person@example.test" })
const result = await createPersonUserUseCase({ const result = await createPersonUserUseCase({
firstName: "Duplicate", firstName: "Duplicate",
lastName: "Person", lastName: "Person",
department: "IT", teamId: team.id,
email: "existing-person@example.test", email: "existing-person@example.test",
phone: null, phone: null,
role: "NO_USER", role: "NO_USER",
@@ -225,12 +241,13 @@ describe("createPersonUserUseCase", () => {
}) })
it("rejects submission when email already exists in User table", async () => { it("rejects submission when email already exists in User table", async () => {
const team = await createTestTeam(prisma)
await createTestUser(prisma, { email: "existing-user@example.test" }) await createTestUser(prisma, { email: "existing-user@example.test" })
const result = await createPersonUserUseCase({ const result = await createPersonUserUseCase({
firstName: "Duplicate", firstName: "Duplicate",
lastName: "User", lastName: "User",
department: "IT", teamId: team.id,
email: "existing-user@example.test", email: "existing-user@example.test",
phone: null, phone: null,
role: "STAFF", role: "STAFF",
@@ -249,6 +266,7 @@ describe("createPersonUserUseCase", () => {
}) })
it("accepts submission when email is unique across both tables", async () => { it("accepts submission when email is unique across both tables", async () => {
const team = await createTestTeam(prisma)
// Create a Person and a User with different emails // Create a Person and a User with different emails
await createTestPerson(prisma, { email: "person@example.test" }) await createTestPerson(prisma, { email: "person@example.test" })
await createTestUser(prisma, { email: "user@example.test" }) await createTestUser(prisma, { email: "user@example.test" })
@@ -256,7 +274,7 @@ describe("createPersonUserUseCase", () => {
const result = await createPersonUserUseCase({ const result = await createPersonUserUseCase({
firstName: "New", firstName: "New",
lastName: "Person", lastName: "Person",
department: "IT", teamId: team.id,
email: "new@example.test", email: "new@example.test",
phone: null, phone: null,
role: "NO_USER", role: "NO_USER",
@@ -266,4 +284,24 @@ describe("createPersonUserUseCase", () => {
expect(result).toEqual({ success: true }) expect(result).toEqual({ success: true })
}) })
}) })
describe("team validation", () => {
it("rejects an unknown team id", async () => {
const result = await createPersonUserUseCase({
firstName: "No",
lastName: "Team",
teamId: "00000000-0000-0000-0000-000000000000",
email: "no-team@example.test",
phone: null,
role: "NO_USER",
isActive: true,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
expect(await prisma.person.count()).toBe(0)
})
})
}) })
@@ -1,6 +1,10 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import type { PrismaClient } from "@/generated/prisma/client" import type { PrismaClient } from "@/generated/prisma/client"
import { createTestPerson, createTestUser } from "../helpers/factories" import {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import { import {
resetIntegrationTestDatabase, resetIntegrationTestDatabase,
startIntegrationTestDatabase, startIntegrationTestDatabase,
@@ -40,7 +44,7 @@ describe("person use-cases", () => {
createPersonUseCase({ createPersonUseCase({
firstName: "Person", firstName: "Person",
lastName: "One", lastName: "One",
department: "IT", teamId: null,
email: "", email: "",
phone: "", phone: "",
}), }),
@@ -53,7 +57,7 @@ describe("person use-cases", () => {
).toMatchObject({ ).toMatchObject({
firstName: "Person", firstName: "Person",
lastName: "One", lastName: "One",
department: "IT", teamId: null,
email: null, email: null,
phone: null, phone: null,
userId: null, userId: null,
@@ -62,12 +66,13 @@ describe("person use-cases", () => {
it("creates a person with linked userId", async () => { it("creates a person with linked userId", async () => {
const user = await createTestUser(prisma) const user = await createTestUser(prisma)
const team = await createTestTeam(prisma)
await expect( await expect(
createPersonUseCase({ createPersonUseCase({
firstName: "Linked", firstName: "Linked",
lastName: "Person", lastName: "Person",
department: "ENGINEERING", teamId: team.id,
email: "linked@example.test", email: "linked@example.test",
phone: null, phone: null,
userId: user.id, userId: user.id,
@@ -81,7 +86,7 @@ describe("person use-cases", () => {
).toMatchObject({ ).toMatchObject({
firstName: "Linked", firstName: "Linked",
lastName: "Person", lastName: "Person",
department: "ENGINEERING", teamId: team.id,
email: "linked@example.test", email: "linked@example.test",
userId: user.id, userId: user.id,
}) })
@@ -96,7 +101,7 @@ describe("person use-cases", () => {
createPersonUseCase({ createPersonUseCase({
firstName: "Duplicate", firstName: "Duplicate",
lastName: "Email", lastName: "Email",
department: "OTHER", teamId: null,
email: "existing@example.test", email: "existing@example.test",
phone: null, phone: null,
}), }),
@@ -108,7 +113,24 @@ describe("person use-cases", () => {
expect(await prisma.person.count()).toBe(1) expect(await prisma.person.count()).toBe(1)
}) })
it("rejects an unknown team id on create", async () => {
const result = await createPersonUseCase({
firstName: "Unknown",
lastName: "Team",
teamId: "00000000-0000-0000-0000-000000000000",
email: "unknown-team@example.test",
phone: null,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
expect(await prisma.person.count()).toBe(0)
})
it("updates a person and rejects duplicate emails", async () => { it("updates a person and rejects duplicate emails", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, { const person = await createTestPerson(prisma, {
email: "person@example.test", email: "person@example.test",
phone: "111111111", phone: "111111111",
@@ -122,16 +144,18 @@ describe("person use-cases", () => {
id: person.id, id: person.id,
firstName: "Edited", firstName: "Edited",
lastName: "Person", lastName: "Person",
department: "ENGINEERING", teamId: team.id,
email: "edited@example.test", email: "edited@example.test",
phone: "222222222", phone: "222222222",
}), }),
).resolves.toEqual({ success: true }) ).resolves.toEqual({ success: true })
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({ expect(
await prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
).toMatchObject({
firstName: "Edited", firstName: "Edited",
lastName: "Person", lastName: "Person",
department: "ENGINEERING", teamId: team.id,
email: "edited@example.test", email: "edited@example.test",
phone: "222222222", phone: "222222222",
}) })
@@ -141,7 +165,7 @@ describe("person use-cases", () => {
id: person.id, id: person.id,
firstName: "Edited", firstName: "Edited",
lastName: "Person", lastName: "Person",
department: "ENGINEERING", teamId: team.id,
email: other.email, email: other.email,
phone: "222222222", phone: "222222222",
}), }),
@@ -150,12 +174,37 @@ describe("person use-cases", () => {
errors: { email: ["Email already exists"] }, errors: { email: ["Email already exists"] },
}) })
expect(await prisma.person.findUniqueOrThrow({ where: { id: person.id } })).toMatchObject({ expect(
await prisma.person.findUniqueOrThrow({ where: { id: person.id } }),
).toMatchObject({
email: "edited@example.test", email: "edited@example.test",
}) })
expect(await prisma.person.count()).toBe(2) expect(await prisma.person.count()).toBe(2)
}) })
it("updates a person team to null", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, {
teamId: team.id,
})
await expect(
updatePersonUseCase({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
teamId: null,
email: person.email,
phone: person.phone,
}),
).resolves.toEqual({ success: true })
const updated = await prisma.person.findUniqueOrThrow({
where: { id: person.id },
})
expect(updated.teamId).toBeNull()
})
it("searches by email and name in paginated results", async () => { it("searches by email and name in paginated results", async () => {
await createTestPerson(prisma, { await createTestPerson(prisma, {
firstName: "Alice", firstName: "Alice",
@@ -2,7 +2,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { type PrismaClient, UserStatus } from "@/generated/prisma/client" import { type PrismaClient, UserStatus } from "@/generated/prisma/client"
import { normalizeEmail } from "@/lib/email" import { normalizeEmail } from "@/lib/email"
import { getPasswordHash } from "@/lib/security" import { getPasswordHash } from "@/lib/security"
import { createTestPerson, createTestUser } from "../helpers/factories" import {
createTestPerson,
createTestTeam,
createTestUser,
} from "../helpers/factories"
import { import {
resetIntegrationTestDatabase, resetIntegrationTestDatabase,
startIntegrationTestDatabase, startIntegrationTestDatabase,
@@ -34,6 +38,7 @@ afterAll(async () => {
describe("updatePersonUserUseCase", () => { describe("updatePersonUserUseCase", () => {
describe("person-only update", () => { describe("person-only update", () => {
it("updates only the Person when person has no linked User", async () => { it("updates only the Person when person has no linked User", async () => {
const team = await createTestTeam(prisma)
const person = await createTestPerson(prisma, { const person = await createTestPerson(prisma, {
firstName: "Old", firstName: "Old",
lastName: "Name", lastName: "Name",
@@ -44,7 +49,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id, id: person.id,
firstName: "New", firstName: "New",
lastName: "Name", lastName: "Name",
department: "IT", teamId: team.id,
email: "new@example.test", email: "new@example.test",
phone: "1234", phone: "1234",
}) })
@@ -57,7 +62,7 @@ describe("updatePersonUserUseCase", () => {
expect(updated).toMatchObject({ expect(updated).toMatchObject({
firstName: "New", firstName: "New",
lastName: "Name", lastName: "Name",
department: "IT", teamId: team.id,
email: "new@example.test", email: "new@example.test",
phone: "1234", phone: "1234",
userId: null, userId: null,
@@ -71,7 +76,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id, id: person.id,
firstName: "Empty", firstName: "Empty",
lastName: "Email", lastName: "Email",
department: "OTHER", teamId: null,
email: "", email: "",
phone: null, phone: null,
}) })
@@ -87,6 +92,7 @@ describe("updatePersonUserUseCase", () => {
describe("person+user update", () => { describe("person+user update", () => {
it("updates Person fields and User role/isActive when person has a User linked", async () => { it("updates Person fields and User role/isActive when person has a User linked", async () => {
const team = await createTestTeam(prisma)
const user = await createTestUser(prisma, { const user = await createTestUser(prisma, {
email: "user-update@example.test", email: "user-update@example.test",
name: "Old Name", name: "Old Name",
@@ -107,7 +113,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id, id: person.id,
firstName: "Linked", firstName: "Linked",
lastName: "Person", lastName: "Person",
department: "ENGINEERING", teamId: team.id,
email: "user-update@example.test", email: "user-update@example.test",
phone: null, phone: null,
role: "ADMIN", role: "ADMIN",
@@ -120,7 +126,7 @@ describe("updatePersonUserUseCase", () => {
where: { id: person.id }, where: { id: person.id },
include: { user: true }, include: { user: true },
}) })
expect(updatedPerson.department).toBe("ENGINEERING") expect(updatedPerson.teamId).toBe(team.id)
expect(updatedPerson.user).toMatchObject({ expect(updatedPerson.user).toMatchObject({
id: user.id, id: user.id,
role: "ADMIN", role: "ADMIN",
@@ -144,7 +150,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id, id: person.id,
firstName: person.firstName, firstName: person.firstName,
lastName: person.lastName, lastName: person.lastName,
department: "OTHER", teamId: null,
email: "pw-reset@example.test", email: "pw-reset@example.test",
phone: null, phone: null,
role: "STAFF", role: "STAFF",
@@ -190,7 +196,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id, id: person.id,
firstName: person.firstName, firstName: person.firstName,
lastName: person.lastName, lastName: person.lastName,
department: "OTHER", teamId: null,
email: "no-pw@example.test", email: "no-pw@example.test",
phone: null, phone: null,
role: "STAFF", role: "STAFF",
@@ -216,7 +222,7 @@ describe("updatePersonUserUseCase", () => {
id: "00000000-0000-0000-0000-000000000000", id: "00000000-0000-0000-0000-000000000000",
firstName: "Ghost", firstName: "Ghost",
lastName: "Person", lastName: "Person",
department: "OTHER", teamId: null,
email: "ghost@example.test", email: "ghost@example.test",
phone: null, phone: null,
}) })
@@ -239,7 +245,7 @@ describe("updatePersonUserUseCase", () => {
id: person.id, id: person.id,
firstName: "Mine", firstName: "Mine",
lastName: "Person", lastName: "Person",
department: "OTHER", teamId: null,
email: "theirs@example.test", email: "theirs@example.test",
phone: null, phone: null,
}) })
@@ -249,5 +255,23 @@ describe("updatePersonUserUseCase", () => {
errors: { email: ["Email already exists"] }, errors: { email: ["Email already exists"] },
}) })
}) })
it("rejects an unknown team id", async () => {
const person = await createTestPerson(prisma)
const result = await updatePersonUserUseCase({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
teamId: "00000000-0000-0000-0000-000000000000",
email: person.email,
phone: null,
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.errors.teamId).toBeDefined()
}
})
}) })
}) })
@@ -147,7 +147,9 @@ describe("user use-cases", () => {
}), }),
).resolves.toEqual({ success: true }) ).resolves.toEqual({ success: true })
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({ expect(
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).toMatchObject({
name: "Edited User", name: "Edited User",
email: "edited@example.test", email: "edited@example.test",
role: "MANAGER", role: "MANAGER",
@@ -168,7 +170,9 @@ describe("user use-cases", () => {
errors: { email: ["Email already exists"] }, errors: { email: ["Email already exists"] },
}) })
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({ expect(
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
).toMatchObject({
email: "edited@example.test", email: "edited@example.test",
}) })
}) })
@@ -190,7 +194,9 @@ describe("user use-cases", () => {
errors: { id: ["You cannot remove your own administrator access"] }, errors: { id: ["You cannot remove your own administrator access"] },
}) })
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({ expect(
await prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).toMatchObject({
role: "ADMIN", role: "ADMIN",
status: "ACTIVE", status: "ACTIVE",
}) })
@@ -232,7 +238,9 @@ describe("user use-cases", () => {
}), }),
).resolves.toEqual({ success: true }) ).resolves.toEqual({ success: true })
expect(await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } })).toMatchObject({ expect(
await prisma.user.findUniqueOrThrow({ where: { id: firstAdmin.id } }),
).toMatchObject({
status: "DISABLED", status: "DISABLED",
}) })
}) })
@@ -252,7 +260,9 @@ describe("user use-cases", () => {
errors: { id: ["You cannot deactivate your own user"] }, errors: { id: ["You cannot deactivate your own user"] },
}) })
expect(await prisma.user.findUniqueOrThrow({ where: { id: admin.id } })).toMatchObject({ expect(
await prisma.user.findUniqueOrThrow({ where: { id: admin.id } }),
).toMatchObject({
status: "ACTIVE", status: "ACTIVE",
}) })
}) })
+8 -5
View File
@@ -25,6 +25,8 @@ vi.mock("@/use-cases/person.use-cases", () => ({
import { createNewPerson, updatePerson } from "@/actions/person.actions" import { createNewPerson, updatePerson } from "@/actions/person.actions"
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
describe("person actions localization", () => { describe("person actions localization", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -35,7 +37,7 @@ describe("person actions localization", () => {
const result = await createNewPerson({ const result = await createNewPerson({
firstName: "", firstName: "",
lastName: "", lastName: "",
department: "", teamId: "not-a-uuid",
email: "not-an-email", email: "not-an-email",
} as unknown as Parameters<typeof createNewPerson>[0]) } as unknown as Parameters<typeof createNewPerson>[0])
@@ -46,7 +48,8 @@ describe("person actions localization", () => {
errors: { errors: {
firstName: [es.inventory.people.schema.firstNameRequired], firstName: [es.inventory.people.schema.firstNameRequired],
lastName: [es.inventory.people.schema.lastNameRequired], lastName: [es.inventory.people.schema.lastNameRequired],
department: [es.inventory.people.schema.departmentRequired], teamId: [es.inventory.people.schema.teamIdInvalid],
email: [es.inventory.people.schema.emailInvalid],
}, },
}) })
}) })
@@ -62,7 +65,7 @@ describe("person actions localization", () => {
const result = await createNewPerson({ const result = await createNewPerson({
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
}) })
@@ -83,7 +86,7 @@ describe("person actions localization", () => {
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
}) })
@@ -98,7 +101,7 @@ describe("person actions localization", () => {
const result = await createNewPerson({ const result = await createNewPerson({
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: null,
userId: "not-a-uuid", userId: "not-a-uuid",
} as unknown as Parameters<typeof createNewPerson>[0]) } as unknown as Parameters<typeof createNewPerson>[0])
@@ -9,6 +9,7 @@ const actionCopy = {
updateFailure: "Error al actualizar la persona", updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe", duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada", notFound: "Persona no encontrada",
teamNotFound: "Equipo no encontrado",
} }
describe("person action message localization", () => { describe("person action message localization", () => {
@@ -25,6 +26,19 @@ describe("person action message localization", () => {
}) })
}) })
it("localizes team not found errors", () => {
expect(
localizePersonFieldErrors(
{
teamId: ["Team not found"],
},
actionCopy,
),
).toEqual({
teamId: [actionCopy.teamNotFound],
})
})
it("keeps unknown messages unchanged", () => { it("keeps unknown messages unchanged", () => {
expect( expect(
localizePersonFieldErrors( localizePersonFieldErrors(
@@ -28,6 +28,8 @@ vi.mock("@/use-cases/person.use-cases", () => ({
import { updatePersonUserAction } from "@/actions/person.actions" import { updatePersonUserAction } from "@/actions/person.actions"
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
describe("updatePersonUserAction", () => { describe("updatePersonUserAction", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -41,7 +43,7 @@ describe("updatePersonUserAction", () => {
id: "", id: "",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
phone: null, phone: null,
}) })
@@ -60,7 +62,7 @@ describe("updatePersonUserAction", () => {
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "not-an-email", email: "not-an-email",
phone: null, phone: null,
}) })
@@ -74,12 +76,31 @@ describe("updatePersonUserAction", () => {
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled() expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
}) })
it("rejects invalid teamId with localized teamIdInvalid error", async () => {
const result = await updatePersonUserAction({
id: "person-1",
firstName: "Ada",
lastName: "Lovelace",
teamId: "not-a-uuid",
email: "ada@example.test",
phone: null,
})
expect(result).toEqual({
success: false,
errors: {
teamId: [es.inventory.people.schema.teamIdInvalid],
},
})
expect(mocks.updatePersonUserUseCase).not.toHaveBeenCalled()
})
it("rejects short password when role is provided", async () => { it("rejects short password when role is provided", async () => {
const result = await updatePersonUserAction({ const result = await updatePersonUserAction({
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
phone: null, phone: null,
role: "ADMIN", role: "ADMIN",
@@ -101,7 +122,7 @@ describe("updatePersonUserAction", () => {
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
phone: null, phone: null,
role: "NO_USER" as unknown as "ADMIN", role: "NO_USER" as unknown as "ADMIN",
@@ -117,7 +138,7 @@ describe("updatePersonUserAction", () => {
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
phone: null, phone: null,
role: "SUPER_ADMIN" as unknown as "ADMIN", role: "SUPER_ADMIN" as unknown as "ADMIN",
@@ -140,7 +161,7 @@ describe("updatePersonUserAction", () => {
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "duplicate@example.test", email: "duplicate@example.test",
phone: null, phone: null,
}) })
@@ -164,7 +185,7 @@ describe("updatePersonUserAction", () => {
id: "missing", id: "missing",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
phone: null, phone: null,
}) })
@@ -184,7 +205,7 @@ describe("updatePersonUserAction", () => {
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
phone: null, phone: null,
}) })
@@ -204,7 +225,7 @@ describe("updatePersonUserAction", () => {
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "ada@example.test", email: "ada@example.test",
phone: null, phone: null,
}) })
@@ -24,7 +24,7 @@ vi.mock("@/components/common/pageheader", () => ({
vi.mock("@/components/ui/button", () => ({ vi.mock("@/components/ui/button", () => ({
Button: ({ children }: { children: React.ReactNode }) => Button: ({ children }: { children: React.ReactNode }) =>
createElement("button", null, children), createElement("button", { type: "button" }, children),
})) }))
vi.mock("next/link", () => ({ vi.mock("next/link", () => ({
@@ -109,7 +109,9 @@ describe("asset detail page", () => {
) )
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
await AssetDetailPage({ params: Promise.resolve({ assetId: "asset-1" }) }), await AssetDetailPage({
params: Promise.resolve({ assetId: "asset-1" }),
}),
) )
expect(html).toContain("Asset Details") expect(html).toContain("Asset Details")
+1 -1
View File
@@ -29,7 +29,7 @@ vi.mock("@/components/common/pagination", () => ({
vi.mock("@/components/ui/button", () => ({ vi.mock("@/components/ui/button", () => ({
Button: ({ children }: { children: React.ReactNode }) => Button: ({ children }: { children: React.ReactNode }) =>
createElement("button", null, children), createElement("button", { type: "button" }, children),
})) }))
vi.mock("next/link", () => ({ vi.mock("next/link", () => ({
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
getI18n: vi.fn(), getI18n: vi.fn(),
findByIdWithUser: vi.fn(), findByIdWithUser: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
listTeamsUseCase: vi.fn(),
personForm: vi.fn(), personForm: vi.fn(),
push: vi.fn(), push: vi.fn(),
toastError: vi.fn(), toastError: vi.fn(),
@@ -27,6 +28,10 @@ vi.mock("@/services/person.service", () => ({
}, },
})) }))
vi.mock("@/use-cases/team.use-cases", () => ({
listTeamsUseCase: mocks.listTeamsUseCase,
}))
vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({ vi.mock("@/app/(dashboard)/people/_components/edit.person.form", () => ({
default: (props: unknown) => { default: (props: unknown) => {
mocks.personForm(props) mocks.personForm(props)
@@ -54,8 +59,8 @@ const basePerson: PersonWithUser = {
id: "person-1", id: "person-1",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING",
teamId: null, teamId: null,
team: null,
email: "ada@example.test", email: "ada@example.test",
phone: "1234", phone: "1234",
userId: null, userId: null,
@@ -80,10 +85,16 @@ const personWithUser: PersonWithUser = {
}, },
} }
const teams = [
{ id: "team-1", name: "Engineering" },
{ id: "team-2", name: "Sales" },
]
describe("edit person page wiring", () => { describe("edit person page wiring", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" }) mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
mocks.listTeamsUseCase.mockResolvedValue(teams)
}) })
it("loads the person without user, passes PersonWithoutUser to the edit form", async () => { it("loads the person without user, passes PersonWithoutUser to the edit form", async () => {
@@ -112,6 +123,7 @@ describe("edit person page wiring", () => {
...en.inventory.people.schema, ...en.inventory.people.schema,
}, },
roleLabels: en.admin.users.roles, roleLabels: en.admin.users.roles,
teams,
}), }),
) )
}) })
@@ -142,10 +154,7 @@ describe("edit person page wiring", () => {
}), }),
formCopy: es.admin.users.form, formCopy: es.admin.users.form,
roleLabels: es.admin.users.roles, roleLabels: es.admin.users.roles,
departmentCopy: es.inventory.people.departments, teams,
fallbackCopy: expect.objectContaining({
unknownDepartment: es.inventory.people.fallback.unknownDepartment,
}),
}), }),
) )
}) })
@@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
getI18n: vi.fn(), getI18n: vi.fn(),
findByIdWithUser: vi.fn(), findByIdWithUser: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
listTeamsUseCase: vi.fn(),
redirect: vi.fn(), redirect: vi.fn(),
personForm: vi.fn(), personForm: vi.fn(),
})) }))
@@ -22,6 +23,10 @@ vi.mock("@/services/person.service", () => ({
}, },
})) }))
vi.mock("@/use-cases/team.use-cases", () => ({
listTeamsUseCase: mocks.listTeamsUseCase,
}))
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: mocks.redirect, redirect: mocks.redirect,
useRouter: () => ({ useRouter: () => ({
@@ -49,10 +54,16 @@ vi.mock("sonner", () => ({
}, },
})) }))
const teams = [
{ id: "team-1", name: "Engineering" },
{ id: "team-2", name: "Sales" },
]
describe("person pages", () => { describe("person pages", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.listTeamsUseCase.mockResolvedValue(teams)
}) })
it("renders the edit person page with Person heading and passes person to unified form", async () => { it("renders the edit person page with Person heading and passes person to unified form", async () => {
@@ -66,7 +77,8 @@ describe("person pages", () => {
lastName: "Lovelace", lastName: "Lovelace",
email: "ada@example.test", email: "ada@example.test",
phone: "1234", phone: "1234",
department: "ENGINEERING", teamId: "team-1",
team: { id: "team-1", name: "Engineering" },
userId: null, userId: null,
isActive: true, isActive: true,
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
@@ -88,6 +100,7 @@ describe("person pages", () => {
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
}), }),
teams,
}), }),
) )
}) })
+14 -8
View File
@@ -67,7 +67,8 @@ describe("person pages", () => {
lastName: "Lovelace", lastName: "Lovelace",
email: "ada@example.test", email: "ada@example.test",
phone: "1234", phone: "1234",
department: "ENGINEERING", teamId: "team-1",
team: { id: "team-1", name: "Engineering" },
userId: null, userId: null,
isActive: true, isActive: true,
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
@@ -87,7 +88,7 @@ describe("person pages", () => {
expect(html).toContain("Add Person") expect(html).toContain("Add Person")
// No username column — username header must not appear // No username column — username header must not appear
expect(html).not.toContain("Username") expect(html).not.toContain("Username")
// No standalone username cell — only name, email, phone, department columns // No standalone username cell — only name, email, phone, team columns
expect(html).not.toContain(">ada<") expect(html).not.toContain(">ada<")
// Name and other fields rendered // Name and other fields rendered
expect(html).toContain("Ada Lovelace") expect(html).toContain("Ada Lovelace")
@@ -110,7 +111,8 @@ describe("person pages", () => {
lastName: "Lovelace", lastName: "Lovelace",
email: "ada@example.test", email: "ada@example.test",
phone: "1234", phone: "1234",
department: "ENGINEERING", teamId: "team-1",
team: { id: "team-1", name: "Engineering" },
userId: "user-1", userId: "user-1",
isActive: true, isActive: true,
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
@@ -137,7 +139,8 @@ describe("person pages", () => {
lastName: "Jones", lastName: "Jones",
email: "bob@example.test", email: "bob@example.test",
phone: null, phone: null,
department: "IT", teamId: null,
team: null,
userId: null, userId: null,
isActive: true, isActive: true,
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
@@ -193,7 +196,8 @@ describe("person pages", () => {
lastName: "Lovelace", lastName: "Lovelace",
email: "ada@example.test", email: "ada@example.test",
phone: "1234", phone: "1234",
department: "DRIVER", teamId: "team-2",
team: { id: "team-2", name: "Driver" },
userId: null, userId: null,
isActive: true, isActive: true,
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
@@ -221,7 +225,7 @@ describe("person pages", () => {
// Person detail fields // Person detail fields
expect(html).toContain("Email") expect(html).toContain("Email")
expect(html).toContain("Phone") expect(html).toContain("Phone")
expect(html).toContain("Department") expect(html).toContain("Team")
expect(html).toContain("ada@example.test") expect(html).toContain("ada@example.test")
expect(html).toContain("Driver") expect(html).toContain("Driver")
// Embedded assignments // Embedded assignments
@@ -239,7 +243,8 @@ describe("person pages", () => {
lastName: "Lovelace", lastName: "Lovelace",
email: "ada@example.test", email: "ada@example.test",
phone: "1234", phone: "1234",
department: "DRIVER", teamId: null,
team: null,
userId: "user-1", userId: "user-1",
isActive: true, isActive: true,
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
@@ -287,7 +292,8 @@ describe("person pages", () => {
lastName: "Lovelace", lastName: "Lovelace",
email: "ada@example.test", email: "ada@example.test",
phone: "1234", phone: "1234",
department: "DRIVER", teamId: null,
team: null,
userId: null, userId: null,
isActive: true, isActive: true,
createdAt: new Date("2024-01-01"), createdAt: new Date("2024-01-01"),
@@ -7,6 +7,7 @@ import { es } from "@/i18n/dictionaries/es"
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createPersonUser: vi.fn(), createPersonUser: vi.fn(),
getI18n: vi.fn(), getI18n: vi.fn(),
listTeamsUseCase: vi.fn(),
push: vi.fn(), push: vi.fn(),
toastError: vi.fn(), toastError: vi.fn(),
toastSuccess: vi.fn(), toastSuccess: vi.fn(),
@@ -26,6 +27,10 @@ vi.mock("@/services/person.service", () => ({
}, },
})) }))
vi.mock("@/use-cases/team.use-cases", () => ({
listTeamsUseCase: mocks.listTeamsUseCase,
}))
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
useRouter: () => ({ useRouter: () => ({
push: mocks.push, push: mocks.push,
@@ -39,10 +44,16 @@ vi.mock("sonner", () => ({
}, },
})) }))
const teams = [
{ id: "team-1", name: "Engineering" },
{ id: "team-2", name: "Sales" },
]
describe("unified creation form page", () => { describe("unified creation form page", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
mocks.listTeamsUseCase.mockResolvedValue(teams)
}) })
it("renders unified form with Person fields, email, password, role, and NO_USER option in Spanish", async () => { it("renders unified form with Person fields, email, password, role, and NO_USER option in Spanish", async () => {
@@ -55,7 +66,7 @@ describe("unified creation form page", () => {
// Person fields // Person fields
expect(html).toContain("Nombre") expect(html).toContain("Nombre")
expect(html).toContain("Apellido") expect(html).toContain("Apellido")
expect(html).toContain("Departamento") expect(html).toContain("Equipo")
expect(html).toContain("Teléfono") expect(html).toContain("Teléfono")
// User fields // User fields
@@ -86,7 +97,7 @@ describe("unified creation form page", () => {
// Person fields // Person fields
expect(html).toContain("First Name") expect(html).toContain("First Name")
expect(html).toContain("Last Name") expect(html).toContain("Last Name")
expect(html).toContain("Department") expect(html).toContain("Team")
expect(html).toContain("Phone") expect(html).toContain("Phone")
// User fields // User fields
@@ -108,18 +119,20 @@ describe("unified creation form page", () => {
// Person field placeholders // Person field placeholders
expect(html).toContain('placeholder="Nombre"') // firstNamePlaceholder (es) expect(html).toContain('placeholder="Nombre"') // firstNamePlaceholder (es)
expect(html).toContain('placeholder="Apellido"') // lastNamePlaceholder (es) expect(html).toContain('placeholder="Apellido"') // lastNamePlaceholder (es)
expect(html).toContain("Selecciona un departamento") // departmentPlaceholder expect(html).toContain("Selecciona un equipo") // teamPlaceholder
expect(html).toContain('placeholder="Teléfono"') // phonePlaceholder (es) expect(html).toContain('placeholder="Teléfono"') // phonePlaceholder (es)
}) })
it("renders department select with all PERSON_DEPARTMENTS values", async () => { it("renders team select with active teams from listTeamsUseCase", async () => {
const { default: NewUserPage } = await import( const { default: NewUserPage } = await import(
"@/app/(dashboard)/people/new/page" "@/app/(dashboard)/people/new/page"
) )
const html = renderToStaticMarkup(await NewUserPage()) const html = renderToStaticMarkup(await NewUserPage())
// Department values must use canonical enum values expect(html).toContain('value="team-1"')
expect(html).toContain('value="ADMINISTRATION"') expect(html).toContain("Engineering")
expect(html).toContain('value="team-2"')
expect(html).toContain("Sales")
}) })
}) })
+1 -49
View File
@@ -1,9 +1,6 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { import { formatUserRole } from "@/app/(dashboard)/people/_components/user.copy"
formatPersonDepartment,
formatUserRole,
} from "@/app/(dashboard)/people/_components/user.copy"
describe("user copy helpers", () => { describe("user copy helpers", () => {
const roleCopy = { const roleCopy = {
@@ -37,48 +34,3 @@ describe("user copy helpers", () => {
).toBe("Rol desconocido") ).toBe("Rol desconocido")
}) })
}) })
describe("formatPersonDepartment helper", () => {
const departmentCopy = {
IT: "IT",
ENGINEERING: "Ingeniería",
LOGISTICS: "Logística",
TRAFFIC: "Tráfico",
DRIVER: "Chofer",
ADMINISTRATION: "Administración",
SALES: "Ventas",
OTHER: "Otro",
}
const fallbackCopy = {
unknownDepartment: "Departamento desconocido",
unknownStatus: "Estado desconocido",
}
it("formats known department values with localized labels", () => {
expect(
formatPersonDepartment("ENGINEERING", departmentCopy, fallbackCopy),
).toBe("Ingeniería")
expect(
formatPersonDepartment("ADMINISTRATION", departmentCopy, fallbackCopy),
).toBe("Administración")
})
it("falls back for unknown department values", () => {
expect(
formatPersonDepartment("UNKNOWN_DEPT", departmentCopy, fallbackCopy),
).toBe("Departamento desconocido")
})
it("falls back for null department values", () => {
expect(formatPersonDepartment(null, departmentCopy, fallbackCopy)).toBe(
"Departamento desconocido",
)
})
it("falls back for undefined department values", () => {
expect(
formatPersonDepartment(undefined, departmentCopy, fallbackCopy),
).toBe("Departamento desconocido")
})
})
@@ -32,8 +32,8 @@ describe("admin users dictionary", () => {
firstNamePlaceholder: "First name", firstNamePlaceholder: "First name",
lastNameLabel: "Last Name", lastNameLabel: "Last Name",
lastNamePlaceholder: "Last name", lastNamePlaceholder: "Last name",
departmentLabel: "Department", teamLabel: "Team",
departmentPlaceholder: "Select a department", teamPlaceholder: "Select a team",
emailLabel: "Email", emailLabel: "Email",
emailPlaceholder: "user@example.com", emailPlaceholder: "user@example.com",
phoneLabel: "Phone", phoneLabel: "Phone",
@@ -127,8 +127,8 @@ describe("admin users dictionary", () => {
firstNamePlaceholder: "Nombre", firstNamePlaceholder: "Nombre",
lastNameLabel: "Apellido", lastNameLabel: "Apellido",
lastNamePlaceholder: "Apellido", lastNamePlaceholder: "Apellido",
departmentLabel: "Departamento", teamLabel: "Equipo",
departmentPlaceholder: "Selecciona un departamento", teamPlaceholder: "Selecciona un equipo",
emailLabel: "Correo electrónico", emailLabel: "Correo electrónico",
emailPlaceholder: "usuario@ejemplo.com", emailPlaceholder: "usuario@ejemplo.com",
phoneLabel: "Teléfono", phoneLabel: "Teléfono",
+14 -32
View File
@@ -832,7 +832,7 @@ describe("i18n dictionaries", () => {
name: "Name", name: "Name",
email: "Email", email: "Email",
phone: "Phone", phone: "Phone",
department: "Department", team: "Team",
role: "Role", role: "Role",
status: "Status", status: "Status",
actions: "Actions", actions: "Actions",
@@ -847,7 +847,7 @@ describe("i18n dictionaries", () => {
labels: { labels: {
email: "Email", email: "Email",
phone: "Phone", phone: "Phone",
department: "Department", team: "Team",
role: "Role", role: "Role",
status: "Status", status: "Status",
noUser: "No user account", noUser: "No user account",
@@ -865,8 +865,8 @@ describe("i18n dictionaries", () => {
firstNamePlaceholder: "First name", firstNamePlaceholder: "First name",
lastNameLabel: "Last Name", lastNameLabel: "Last Name",
lastNamePlaceholder: "Last name", lastNamePlaceholder: "Last name",
departmentLabel: "Department", teamLabel: "Team",
departmentPlaceholder: "Select a department", teamPlaceholder: "Select a team",
emailLabel: "Email", emailLabel: "Email",
emailPlaceholder: "Email", emailPlaceholder: "Email",
phoneLabel: "Phone", phoneLabel: "Phone",
@@ -880,19 +880,9 @@ describe("i18n dictionaries", () => {
updateSubmit: "Update Person", updateSubmit: "Update Person",
}, },
fallback: { fallback: {
unknownDepartment: "Unknown department", noTeam: "",
unknownStatus: "Unknown status", unknownStatus: "Unknown status",
}, },
departments: {
IT: "IT",
ENGINEERING: "Engineering",
LOGISTICS: "Logistics",
TRAFFIC: "Traffic",
DRIVER: "Driver",
ADMINISTRATION: "Administration",
SALES: "Sales",
OTHER: "Other",
},
actions: { actions: {
createSuccess: "Person created successfully", createSuccess: "Person created successfully",
createFailure: "Failed to create person", createFailure: "Failed to create person",
@@ -900,14 +890,15 @@ describe("i18n dictionaries", () => {
updateFailure: "Failed to update person", updateFailure: "Failed to update person",
duplicateEmail: "Email already exists", duplicateEmail: "Email already exists",
notFound: "Person not found", notFound: "Person not found",
teamNotFound: "Team not found",
}, },
schema: { schema: {
firstNameRequired: "First name is required", firstNameRequired: "First name is required",
lastNameRequired: "Last name is required", lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Email format is invalid", emailInvalid: "Email format is invalid",
idRequired: "ID is required", idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID", userIdInvalid: "User ID must be a valid UUID",
teamIdInvalid: "Team must be a valid id",
}, },
}) })
@@ -920,7 +911,7 @@ describe("i18n dictionaries", () => {
name: "Nombre", name: "Nombre",
email: "Correo electrónico", email: "Correo electrónico",
phone: "Teléfono", phone: "Teléfono",
department: "Departamento", team: "Equipo",
role: "Rol", role: "Rol",
status: "Estado", status: "Estado",
actions: "Acciones", actions: "Acciones",
@@ -935,7 +926,7 @@ describe("i18n dictionaries", () => {
labels: { labels: {
email: "Correo electrónico", email: "Correo electrónico",
phone: "Teléfono", phone: "Teléfono",
department: "Departamento", team: "Equipo",
role: "Rol", role: "Rol",
status: "Estado", status: "Estado",
noUser: "Sin cuenta de usuario", noUser: "Sin cuenta de usuario",
@@ -953,8 +944,8 @@ describe("i18n dictionaries", () => {
firstNamePlaceholder: "Nombre", firstNamePlaceholder: "Nombre",
lastNameLabel: "Apellido", lastNameLabel: "Apellido",
lastNamePlaceholder: "Apellido", lastNamePlaceholder: "Apellido",
departmentLabel: "Departamento", teamLabel: "Equipo",
departmentPlaceholder: "Selecciona un departamento", teamPlaceholder: "Selecciona un equipo",
emailLabel: "Correo electrónico", emailLabel: "Correo electrónico",
emailPlaceholder: "Correo electrónico", emailPlaceholder: "Correo electrónico",
phoneLabel: "Teléfono", phoneLabel: "Teléfono",
@@ -969,19 +960,9 @@ describe("i18n dictionaries", () => {
updateSubmit: "Actualizar persona", updateSubmit: "Actualizar persona",
}, },
fallback: { fallback: {
unknownDepartment: "Departamento desconocido", noTeam: "",
unknownStatus: "Estado desconocido", unknownStatus: "Estado desconocido",
}, },
departments: {
IT: "IT",
ENGINEERING: "Ingeniería",
LOGISTICS: "Logística",
TRAFFIC: "Tráfico",
DRIVER: "Chofer",
ADMINISTRATION: "Administración",
SALES: "Ventas",
OTHER: "Otro",
},
actions: { actions: {
createSuccess: "Persona creada correctamente", createSuccess: "Persona creada correctamente",
createFailure: "Error al crear la persona", createFailure: "Error al crear la persona",
@@ -989,14 +970,15 @@ describe("i18n dictionaries", () => {
updateFailure: "Error al actualizar la persona", updateFailure: "Error al actualizar la persona",
duplicateEmail: "El correo electrónico ya existe", duplicateEmail: "El correo electrónico ya existe",
notFound: "Persona no encontrada", notFound: "Persona no encontrada",
teamNotFound: "Equipo no encontrado",
}, },
schema: { schema: {
firstNameRequired: "El nombre es obligatorio", firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio", lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "El correo electrónico no es válido", emailInvalid: "El correo electrónico no es válido",
idRequired: "El ID es obligatorio", idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido", userIdInvalid: "El ID de usuario debe ser un UUID válido",
teamIdInvalid: "El equipo debe ser un id válido",
}, },
}) })
}) })
@@ -10,8 +10,8 @@ describe("admin users unified form dictionary", () => {
expect(form.firstNamePlaceholder).toBe("First name") expect(form.firstNamePlaceholder).toBe("First name")
expect(form.lastNameLabel).toBe("Last Name") expect(form.lastNameLabel).toBe("Last Name")
expect(form.lastNamePlaceholder).toBe("Last name") expect(form.lastNamePlaceholder).toBe("Last name")
expect(form.departmentLabel).toBe("Department") expect(form.teamLabel).toBe("Team")
expect(form.departmentPlaceholder).toBe("Select a department") expect(form.teamPlaceholder).toBe("Select a team")
expect(form.phoneLabel).toBe("Phone") expect(form.phoneLabel).toBe("Phone")
expect(form.phonePlaceholder).toBe("Phone") expect(form.phonePlaceholder).toBe("Phone")
}) })
@@ -23,8 +23,8 @@ describe("admin users unified form dictionary", () => {
expect(form.firstNamePlaceholder).toBe("Nombre") expect(form.firstNamePlaceholder).toBe("Nombre")
expect(form.lastNameLabel).toBe("Apellido") expect(form.lastNameLabel).toBe("Apellido")
expect(form.lastNamePlaceholder).toBe("Apellido") expect(form.lastNamePlaceholder).toBe("Apellido")
expect(form.departmentLabel).toBe("Departamento") expect(form.teamLabel).toBe("Equipo")
expect(form.departmentPlaceholder).toBe("Selecciona un departamento") expect(form.teamPlaceholder).toBe("Selecciona un equipo")
expect(form.phoneLabel).toBe("Teléfono") expect(form.phoneLabel).toBe("Teléfono")
expect(form.phonePlaceholder).toBe("Teléfono") expect(form.phonePlaceholder).toBe("Teléfono")
}) })
+3 -3
View File
@@ -125,7 +125,7 @@ describe("core schemas", () => {
createPersonSchema.safeParse({ createPersonSchema.safeParse({
firstName: "Per", firstName: "Per",
lastName: "Son", lastName: "Son",
department: "IT", teamId: null,
email: "person@example.test", email: "person@example.test",
}).success, }).success,
).toBe(true) ).toBe(true)
@@ -134,7 +134,7 @@ describe("core schemas", () => {
createPersonSchema.safeParse({ createPersonSchema.safeParse({
firstName: "Per", firstName: "Per",
lastName: "Son", lastName: "Son",
department: "IT", teamId: null,
email: "not-an-email", email: "not-an-email",
}).success, }).success,
).toBe(false) ).toBe(false)
@@ -143,7 +143,7 @@ describe("core schemas", () => {
createPersonSchema.safeParse({ createPersonSchema.safeParse({
firstName: "Per", firstName: "Per",
lastName: "Son", lastName: "Son",
department: "IT", teamId: null,
email: "", email: "",
}).success, }).success,
).toBe(true) ).toBe(true)
+28 -12
View File
@@ -8,18 +8,20 @@ import {
const schemaCopy = { const schemaCopy = {
firstNameRequired: "El nombre es obligatorio", firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio", lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "El correo electrónico no es válido", emailInvalid: "El correo electrónico no es válido",
idRequired: "El ID es obligatorio", idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido", userIdInvalid: "El ID de usuario debe ser un UUID válido",
teamIdInvalid: "El equipo debe ser un id válido",
} }
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
describe("person schema validation", () => { describe("person schema validation", () => {
it("uses localized required-field validation messages for create (no username)", () => { it("uses localized required-field validation messages for create (no username)", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({ const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "", firstName: "",
lastName: "", lastName: "",
department: "", teamId: null,
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
@@ -28,7 +30,6 @@ describe("person schema validation", () => {
expect(errors.firstName).toContain(schemaCopy.firstNameRequired) expect(errors.firstName).toContain(schemaCopy.firstNameRequired)
expect(errors.lastName).toContain(schemaCopy.lastNameRequired) expect(errors.lastName).toContain(schemaCopy.lastNameRequired)
expect(errors.department).toContain(schemaCopy.departmentRequired)
} }
}) })
@@ -36,7 +37,7 @@ describe("person schema validation", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({ const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: null,
email: "not-an-email", email: "not-an-email",
}) })
@@ -52,7 +53,7 @@ describe("person schema validation", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({ const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: null,
userId: "not-a-uuid", userId: "not-a-uuid",
}) })
@@ -64,12 +65,27 @@ describe("person schema validation", () => {
} }
}) })
it("rejects an invalid teamId", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada",
lastName: "Lovelace",
teamId: "not-a-uuid",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.teamId).toContain(
schemaCopy.teamIdInvalid,
)
}
})
it("uses localized update identifier validation messages", () => { it("uses localized update identifier validation messages", () => {
const result = buildUpdatePersonSchema(schemaCopy).safeParse({ const result = buildUpdatePersonSchema(schemaCopy).safeParse({
id: "", id: "",
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: null,
email: "ada@example.test", email: "ada@example.test",
}) })
@@ -81,20 +97,20 @@ describe("person schema validation", () => {
} }
}) })
it("preserves canonical department values and accepts optional userId UUID", () => { it("accepts a valid teamId UUID and optional userId UUID", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({ const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: validTeamId,
email: "", email: "",
userId: "550e8400-e29b-41d4-a716-446655440000", userId: validTeamId,
}) })
expect(result.success).toBe(true) expect(result.success).toBe(true)
if (result.success) { if (result.success) {
expect(result.data.department).toBe("ENGINEERING") expect(result.data.teamId).toBe(validTeamId)
expect(result.data.email).toBe("") expect(result.data.email).toBe("")
expect(result.data.userId).toBe("550e8400-e29b-41d4-a716-446655440000") expect(result.data.userId).toBe(validTeamId)
} }
}) })
@@ -102,7 +118,7 @@ describe("person schema validation", () => {
const result = buildCreatePersonSchema(schemaCopy).safeParse({ const result = buildCreatePersonSchema(schemaCopy).safeParse({
firstName: "Ada", firstName: "Ada",
lastName: "Lovelace", lastName: "Lovelace",
department: "ENGINEERING", teamId: null,
}) })
expect(result.success).toBe(true) expect(result.success).toBe(true)
@@ -6,34 +6,36 @@ import {
unifiedFormRoleSchema, unifiedFormRoleSchema,
} from "@/schemas/user.schema" } from "@/schemas/user.schema"
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
const enCopy: UnifiedSchemaCopy = { const enCopy: UnifiedSchemaCopy = {
firstNameRequired: "First name is required", firstNameRequired: "First name is required",
lastNameRequired: "Last name is required", lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Invalid email", emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters", passwordMinLength: "Password must be at least 8 characters",
nameRequired: "Name is required", nameRequired: "Name is required",
userIdRequired: "User id is required", userIdRequired: "User id is required",
idRequired: "ID is required", idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID", userIdInvalid: "User ID must be a valid UUID",
teamIdInvalid: "Team must be a valid id",
} }
const esCopy: UnifiedSchemaCopy = { const esCopy: UnifiedSchemaCopy = {
firstNameRequired: "El nombre es obligatorio", firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio", lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "Correo electrónico no válido", emailInvalid: "Correo electrónico no válido",
passwordMinLength: "La contraseña debe tener al menos 8 caracteres", passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
nameRequired: "El nombre es obligatorio", nameRequired: "El nombre es obligatorio",
userIdRequired: "El ID de usuario es obligatorio", userIdRequired: "El ID de usuario es obligatorio",
idRequired: "El ID es obligatorio", idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido", userIdInvalid: "El ID de usuario debe ser un UUID válido",
teamIdInvalid: "El equipo debe ser un id válido",
} }
const validPersonOnlyData = { const validPersonOnlyData = {
firstName: "John", firstName: "John",
lastName: "Doe", lastName: "Doe",
department: "IT", teamId: null,
email: "john@example.test", email: "john@example.test",
phone: null, phone: null,
role: "NO_USER" as const, role: "NO_USER" as const,
@@ -44,7 +46,7 @@ const validPersonOnlyData = {
const validPersonWithUserData = { const validPersonWithUserData = {
firstName: "Jane", firstName: "Jane",
lastName: "Smith", lastName: "Smith",
department: "ENGINEERING", teamId: validTeamId,
email: "jane@example.test", email: "jane@example.test",
phone: "1234567890", phone: "1234567890",
role: "ADMIN" as const, role: "ADMIN" as const,
@@ -96,7 +98,7 @@ describe("buildUnifiedCreateSchema", () => {
const result = schema.safeParse({ const result = schema.safeParse({
firstName: "", firstName: "",
lastName: "", lastName: "",
department: "", teamId: "not-a-uuid",
email: "not-an-email", email: "not-an-email",
role: "NO_USER", role: "NO_USER",
phone: null, phone: null,
@@ -108,7 +110,7 @@ describe("buildUnifiedCreateSchema", () => {
const errors = result.error.flatten().fieldErrors const errors = result.error.flatten().fieldErrors
expect(errors.firstName).toContain(esCopy.firstNameRequired) expect(errors.firstName).toContain(esCopy.firstNameRequired)
expect(errors.lastName).toContain(esCopy.lastNameRequired) expect(errors.lastName).toContain(esCopy.lastNameRequired)
expect(errors.department).toContain(esCopy.departmentRequired) expect(errors.teamId).toContain(esCopy.teamIdInvalid)
expect(errors.email).toContain(esCopy.emailInvalid) expect(errors.email).toContain(esCopy.emailInvalid)
} }
}) })
@@ -185,7 +187,7 @@ describe("buildUnifiedCreateSchema", () => {
const result = schema.safeParse({ const result = schema.safeParse({
firstName: "Jane", firstName: "Jane",
lastName: "Smith", lastName: "Smith",
department: "ENGINEERING", teamId: validTeamId,
email: "jane@example.test", email: "jane@example.test",
role: "ADMIN", role: "ADMIN",
password: "corta", password: "corta",
@@ -228,36 +230,35 @@ describe("buildUnifiedCreateSchema", () => {
}) })
}) })
describe("department validation", () => { describe("teamId validation", () => {
it("rejects invalid department", () => { it("rejects invalid teamId", () => {
const schema = buildUnifiedCreateSchema(enCopy) const schema = buildUnifiedCreateSchema(enCopy)
const result = schema.safeParse({ const result = schema.safeParse({
...validPersonOnlyData, ...validPersonOnlyData,
department: "INVALID_DEPT", teamId: "INVALID_TEAM",
}) })
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
it("accepts valid departments", () => { it("accepts null teamId", () => {
const schema = buildUnifiedCreateSchema(enCopy) const schema = buildUnifiedCreateSchema(enCopy)
const validDepartments = [ const result = schema.safeParse({
"IT", ...validPersonOnlyData,
"ENGINEERING", teamId: null,
"TRAFFIC", })
"DRIVER",
"LOGISTICS", expect(result.success).toBe(true)
"ADMINISTRATION", })
"SALES",
"OTHER", it("accepts a valid teamId UUID", () => {
] const schema = buildUnifiedCreateSchema(enCopy)
for (const dept of validDepartments) { const result = schema.safeParse({
const result = schema.safeParse({ ...validPersonOnlyData,
...validPersonOnlyData, teamId: validTeamId,
department: dept, })
})
expect(result.success).toBe(true) expect(result.success).toBe(true)
}
}) })
}) })
}) })
@@ -5,35 +5,37 @@ import {
type UnifiedSchemaCopy, type UnifiedSchemaCopy,
} from "@/schemas/user.schema" } from "@/schemas/user.schema"
const validTeamId = "550e8400-e29b-41d4-a716-446655440000"
const enCopy: UnifiedSchemaCopy = { const enCopy: UnifiedSchemaCopy = {
firstNameRequired: "First name is required", firstNameRequired: "First name is required",
lastNameRequired: "Last name is required", lastNameRequired: "Last name is required",
departmentRequired: "Department is required",
emailInvalid: "Invalid email", emailInvalid: "Invalid email",
passwordMinLength: "Password must be at least 8 characters", passwordMinLength: "Password must be at least 8 characters",
nameRequired: "Name is required", nameRequired: "Name is required",
userIdRequired: "User id is required", userIdRequired: "User id is required",
idRequired: "ID is required", idRequired: "ID is required",
userIdInvalid: "User ID must be a valid UUID", userIdInvalid: "User ID must be a valid UUID",
teamIdInvalid: "Team must be a valid id",
} }
const esCopy: UnifiedSchemaCopy = { const esCopy: UnifiedSchemaCopy = {
firstNameRequired: "El nombre es obligatorio", firstNameRequired: "El nombre es obligatorio",
lastNameRequired: "El apellido es obligatorio", lastNameRequired: "El apellido es obligatorio",
departmentRequired: "El departamento es obligatorio",
emailInvalid: "Correo electrónico no válido", emailInvalid: "Correo electrónico no válido",
passwordMinLength: "La contraseña debe tener al menos 8 caracteres", passwordMinLength: "La contraseña debe tener al menos 8 caracteres",
nameRequired: "El nombre es obligatorio", nameRequired: "El nombre es obligatorio",
userIdRequired: "El ID de usuario es obligatorio", userIdRequired: "El ID de usuario es obligatorio",
idRequired: "El ID es obligatorio", idRequired: "El ID es obligatorio",
userIdInvalid: "El ID de usuario debe ser un UUID válido", userIdInvalid: "El ID de usuario debe ser un UUID válido",
teamIdInvalid: "El equipo debe ser un id válido",
} }
const validPersonOnly = { const validPersonOnly = {
id: "person-1", id: "person-1",
firstName: "John", firstName: "John",
lastName: "Doe", lastName: "Doe",
department: "IT", teamId: null,
email: "john@example.test", email: "john@example.test",
phone: null, phone: null,
} }
@@ -73,7 +75,7 @@ describe("buildUnifiedUpdateSchema", () => {
id: "", id: "",
firstName: "John", firstName: "John",
lastName: "Doe", lastName: "Doe",
department: "IT", teamId: null,
email: "john@example.test", email: "john@example.test",
phone: null, phone: null,
}) })
@@ -85,6 +87,31 @@ describe("buildUnifiedUpdateSchema", () => {
) )
} }
}) })
it("rejects invalid teamId", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnly,
teamId: "not-a-uuid",
})
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.flatten().fieldErrors.teamId).toContain(
enCopy.teamIdInvalid,
)
}
})
it("accepts a valid teamId UUID", () => {
const schema = buildUnifiedUpdateSchema(enCopy)
const result = schema.safeParse({
...validPersonOnly,
teamId: validTeamId,
})
expect(result.success).toBe(true)
})
}) })
describe("person+user update (when person has User linked)", () => { describe("person+user update (when person has User linked)", () => {
+1 -1
View File
@@ -5,8 +5,8 @@ vi.mock("@/lib/prisma", () => ({
})) }))
import { import {
getUserById,
getUserByEmail, getUserByEmail,
getUserById,
getUserCredentialsByEmail, getUserCredentialsByEmail,
} from "@/services/user.service" } from "@/services/user.service"