feat(teams): add Team entity and cutover Person.department to Person.teamId
Co-authored-by: Asis Ferrer <aferrer@aferrer.dev> Co-committed-by: Asis Ferrer <aferrer@aferrer.dev>
This commit was merged in pull request #5.
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import type { PrismaClient } from "@/generated/prisma/client"
|
||||
import { normalizeEmail } from "@/lib/email"
|
||||
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||
import {
|
||||
createTestPerson,
|
||||
createTestTeam,
|
||||
createTestUser,
|
||||
} from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
@@ -33,10 +37,12 @@ afterAll(async () => {
|
||||
describe("createPersonUserUseCase", () => {
|
||||
describe("NO_USER role (person-only creation)", () => {
|
||||
it("creates a Person without a User record when role is NO_USER", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "john@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
@@ -51,7 +57,7 @@ describe("createPersonUserUseCase", () => {
|
||||
expect(person).toMatchObject({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "john@example.test",
|
||||
phone: 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 () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Jane",
|
||||
lastName: "Smith",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "jane-noemail@example.test",
|
||||
phone: "555-1234",
|
||||
role: "NO_USER",
|
||||
@@ -87,10 +95,12 @@ describe("createPersonUserUseCase", () => {
|
||||
|
||||
describe("real role (person + user creation)", () => {
|
||||
it("creates Person and User with linked userId when role is ADMIN", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Admin",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "admin@example.test",
|
||||
phone: null,
|
||||
role: "ADMIN",
|
||||
@@ -101,12 +111,12 @@ describe("createPersonUserUseCase", () => {
|
||||
expect(result).toEqual({ success: true })
|
||||
|
||||
const person = await prisma.person.findFirstOrThrow({
|
||||
where: { firstName: "Admin", lastName: "User" },
|
||||
where: { firstName: "Admin" },
|
||||
})
|
||||
expect(person).toMatchObject({
|
||||
firstName: "Admin",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "admin@example.test",
|
||||
})
|
||||
|
||||
@@ -128,6 +138,7 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
for (const role of roles) {
|
||||
@@ -135,7 +146,7 @@ describe("createPersonUserUseCase", () => {
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Person",
|
||||
lastName: suffix,
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: `${suffix}@example.test`,
|
||||
phone: null,
|
||||
role,
|
||||
@@ -160,10 +171,12 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("derives User.name from firstName + lastName", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
await createPersonUserUseCase({
|
||||
firstName: "Maria",
|
||||
lastName: "Garcia",
|
||||
department: "SALES",
|
||||
teamId: team.id,
|
||||
email: "maria@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -178,10 +191,12 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("hashes the password when creating a User", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
await createPersonUserUseCase({
|
||||
firstName: "Hash",
|
||||
lastName: "Test",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "hash-test@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -196,20 +211,21 @@ describe("createPersonUserUseCase", () => {
|
||||
if (!user.passwordHash) throw new Error("Expected password hash")
|
||||
|
||||
const { verifyPassword } = await import("@/lib/security")
|
||||
expect(await verifyPassword("plaintext-password", user.passwordHash)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
await verifyPassword("plaintext-password", user.passwordHash),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cross-table email uniqueness", () => {
|
||||
it("rejects submission when email already exists in Person table", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
await createTestPerson(prisma, { email: "existing-person@example.test" })
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Duplicate",
|
||||
lastName: "Person",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "existing-person@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
@@ -225,12 +241,13 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
it("rejects submission when email already exists in User table", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
await createTestUser(prisma, { email: "existing-user@example.test" })
|
||||
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "Duplicate",
|
||||
lastName: "User",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "existing-user@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -249,6 +266,7 @@ describe("createPersonUserUseCase", () => {
|
||||
})
|
||||
|
||||
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
|
||||
await createTestPerson(prisma, { email: "person@example.test" })
|
||||
await createTestUser(prisma, { email: "user@example.test" })
|
||||
@@ -256,7 +274,7 @@ describe("createPersonUserUseCase", () => {
|
||||
const result = await createPersonUserUseCase({
|
||||
firstName: "New",
|
||||
lastName: "Person",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "new@example.test",
|
||||
phone: null,
|
||||
role: "NO_USER",
|
||||
@@ -266,4 +284,24 @@ describe("createPersonUserUseCase", () => {
|
||||
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 type { PrismaClient } from "@/generated/prisma/client"
|
||||
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||
import {
|
||||
createTestPerson,
|
||||
createTestTeam,
|
||||
createTestUser,
|
||||
} from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
@@ -40,7 +44,7 @@ describe("person use-cases", () => {
|
||||
createPersonUseCase({
|
||||
firstName: "Person",
|
||||
lastName: "One",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: "",
|
||||
phone: "",
|
||||
}),
|
||||
@@ -53,7 +57,7 @@ describe("person use-cases", () => {
|
||||
).toMatchObject({
|
||||
firstName: "Person",
|
||||
lastName: "One",
|
||||
department: "IT",
|
||||
teamId: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
userId: null,
|
||||
@@ -62,12 +66,13 @@ describe("person use-cases", () => {
|
||||
|
||||
it("creates a person with linked userId", async () => {
|
||||
const user = await createTestUser(prisma)
|
||||
const team = await createTestTeam(prisma)
|
||||
|
||||
await expect(
|
||||
createPersonUseCase({
|
||||
firstName: "Linked",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "linked@example.test",
|
||||
phone: null,
|
||||
userId: user.id,
|
||||
@@ -81,7 +86,7 @@ describe("person use-cases", () => {
|
||||
).toMatchObject({
|
||||
firstName: "Linked",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "linked@example.test",
|
||||
userId: user.id,
|
||||
})
|
||||
@@ -96,7 +101,7 @@ describe("person use-cases", () => {
|
||||
createPersonUseCase({
|
||||
firstName: "Duplicate",
|
||||
lastName: "Email",
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "existing@example.test",
|
||||
phone: null,
|
||||
}),
|
||||
@@ -108,7 +113,24 @@ describe("person use-cases", () => {
|
||||
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 () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
const person = await createTestPerson(prisma, {
|
||||
email: "person@example.test",
|
||||
phone: "111111111",
|
||||
@@ -122,16 +144,18 @@ describe("person use-cases", () => {
|
||||
id: person.id,
|
||||
firstName: "Edited",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "edited@example.test",
|
||||
phone: "222222222",
|
||||
}),
|
||||
).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",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "edited@example.test",
|
||||
phone: "222222222",
|
||||
})
|
||||
@@ -141,7 +165,7 @@ describe("person use-cases", () => {
|
||||
id: person.id,
|
||||
firstName: "Edited",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: other.email,
|
||||
phone: "222222222",
|
||||
}),
|
||||
@@ -150,12 +174,37 @@ describe("person use-cases", () => {
|
||||
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",
|
||||
})
|
||||
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 () => {
|
||||
await createTestPerson(prisma, {
|
||||
firstName: "Alice",
|
||||
|
||||
@@ -2,7 +2,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { type PrismaClient, UserStatus } from "@/generated/prisma/client"
|
||||
import { normalizeEmail } from "@/lib/email"
|
||||
import { getPasswordHash } from "@/lib/security"
|
||||
import { createTestPerson, createTestUser } from "../helpers/factories"
|
||||
import {
|
||||
createTestPerson,
|
||||
createTestTeam,
|
||||
createTestUser,
|
||||
} from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
@@ -34,6 +38,7 @@ afterAll(async () => {
|
||||
describe("updatePersonUserUseCase", () => {
|
||||
describe("person-only update", () => {
|
||||
it("updates only the Person when person has no linked User", async () => {
|
||||
const team = await createTestTeam(prisma)
|
||||
const person = await createTestPerson(prisma, {
|
||||
firstName: "Old",
|
||||
lastName: "Name",
|
||||
@@ -44,7 +49,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: "New",
|
||||
lastName: "Name",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "new@example.test",
|
||||
phone: "1234",
|
||||
})
|
||||
@@ -57,7 +62,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
expect(updated).toMatchObject({
|
||||
firstName: "New",
|
||||
lastName: "Name",
|
||||
department: "IT",
|
||||
teamId: team.id,
|
||||
email: "new@example.test",
|
||||
phone: "1234",
|
||||
userId: null,
|
||||
@@ -71,7 +76,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: "Empty",
|
||||
lastName: "Email",
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "",
|
||||
phone: null,
|
||||
})
|
||||
@@ -87,6 +92,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
|
||||
describe("person+user update", () => {
|
||||
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, {
|
||||
email: "user-update@example.test",
|
||||
name: "Old Name",
|
||||
@@ -107,7 +113,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: "Linked",
|
||||
lastName: "Person",
|
||||
department: "ENGINEERING",
|
||||
teamId: team.id,
|
||||
email: "user-update@example.test",
|
||||
phone: null,
|
||||
role: "ADMIN",
|
||||
@@ -120,7 +126,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
where: { id: person.id },
|
||||
include: { user: true },
|
||||
})
|
||||
expect(updatedPerson.department).toBe("ENGINEERING")
|
||||
expect(updatedPerson.teamId).toBe(team.id)
|
||||
expect(updatedPerson.user).toMatchObject({
|
||||
id: user.id,
|
||||
role: "ADMIN",
|
||||
@@ -144,7 +150,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "pw-reset@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -190,7 +196,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "no-pw@example.test",
|
||||
phone: null,
|
||||
role: "STAFF",
|
||||
@@ -216,7 +222,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
firstName: "Ghost",
|
||||
lastName: "Person",
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "ghost@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -239,7 +245,7 @@ describe("updatePersonUserUseCase", () => {
|
||||
id: person.id,
|
||||
firstName: "Mine",
|
||||
lastName: "Person",
|
||||
department: "OTHER",
|
||||
teamId: null,
|
||||
email: "theirs@example.test",
|
||||
phone: null,
|
||||
})
|
||||
@@ -249,5 +255,23 @@ describe("updatePersonUserUseCase", () => {
|
||||
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 })
|
||||
|
||||
expect(await prisma.user.findUniqueOrThrow({ where: { id: user.id } })).toMatchObject({
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
|
||||
).toMatchObject({
|
||||
name: "Edited User",
|
||||
email: "edited@example.test",
|
||||
role: "MANAGER",
|
||||
@@ -168,7 +170,9 @@ describe("user use-cases", () => {
|
||||
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",
|
||||
})
|
||||
})
|
||||
@@ -190,7 +194,9 @@ describe("user use-cases", () => {
|
||||
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",
|
||||
status: "ACTIVE",
|
||||
})
|
||||
@@ -232,7 +238,9 @@ describe("user use-cases", () => {
|
||||
}),
|
||||
).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",
|
||||
})
|
||||
})
|
||||
@@ -252,7 +260,9 @@ describe("user use-cases", () => {
|
||||
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",
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user