feat(teams): add team service and use-cases with integration tests
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import type { Prisma } from "@/generated/prisma/client"
|
||||
import prisma from "@/lib/prisma"
|
||||
import type { Team, TeamSummary } from "@/types"
|
||||
|
||||
export const TeamService = {
|
||||
findAll: async (
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<TeamSummary[]> => {
|
||||
return db.team.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
},
|
||||
|
||||
findById: async (
|
||||
id: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team | null> => {
|
||||
return db.team.findUnique({ where: { id } })
|
||||
},
|
||||
|
||||
findByNameCaseInsensitive: async (
|
||||
name: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team | null> => {
|
||||
return db.team.findFirst({
|
||||
where: {
|
||||
name: { equals: name.trim(), mode: "insensitive" },
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
create: async (
|
||||
data: Prisma.TeamCreateInput,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team> => {
|
||||
return db.team.create({ data })
|
||||
},
|
||||
|
||||
update: async (
|
||||
id: string,
|
||||
data: Prisma.TeamUpdateInput,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team> => {
|
||||
return db.team.update({ where: { id }, data })
|
||||
},
|
||||
|
||||
delete: async (
|
||||
id: string,
|
||||
db: Prisma.TransactionClient | typeof prisma = prisma,
|
||||
): Promise<Team> => {
|
||||
return db.team.delete({ where: { id } })
|
||||
},
|
||||
}
|
||||
@@ -6,4 +6,5 @@ export * from "./item"
|
||||
export * from "./movement"
|
||||
export * from "./paginate"
|
||||
export * from "./person"
|
||||
export * from "./team"
|
||||
export * from "./user"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Team as PrismaTeam } from "@/generated/prisma/client"
|
||||
|
||||
export type Team = PrismaTeam
|
||||
|
||||
export type TeamSummary = Pick<Team, "id" | "name">
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Prisma } from "@/generated/prisma/client"
|
||||
import prisma from "@/lib/prisma"
|
||||
import type {
|
||||
CreateTeamFormType,
|
||||
UpdateTeamFormType,
|
||||
} from "@/schemas/team.schema"
|
||||
import { TeamService } from "@/services/team.service"
|
||||
|
||||
type FieldErrors = Record<string, string[]>
|
||||
|
||||
type TeamUseCaseResult =
|
||||
| {
|
||||
success: true
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
errors: FieldErrors
|
||||
}
|
||||
|
||||
function teamError(errors: FieldErrors): TeamUseCaseResult {
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown) {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
)
|
||||
}
|
||||
|
||||
export async function listTeamsUseCase() {
|
||||
return TeamService.findAll()
|
||||
}
|
||||
|
||||
export async function createTeamUseCase(
|
||||
input: CreateTeamFormType,
|
||||
): Promise<TeamUseCaseResult> {
|
||||
const name = input.name.trim()
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existingTeam = await TeamService.findByNameCaseInsensitive(name, tx)
|
||||
|
||||
if (existingTeam) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
await TeamService.create({ name }, tx)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTeamUseCase(
|
||||
input: UpdateTeamFormType,
|
||||
): Promise<TeamUseCaseResult> {
|
||||
const { id } = input
|
||||
const name = input.name.trim()
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const existingTeam = await TeamService.findById(id, tx)
|
||||
|
||||
if (!existingTeam) {
|
||||
return teamError({ id: ["Team not found"] })
|
||||
}
|
||||
|
||||
if (existingTeam.name.toLowerCase() === name.toLowerCase()) {
|
||||
return teamError({ name: ["Team name is the same"] })
|
||||
}
|
||||
|
||||
const teamWithName = await TeamService.findByNameCaseInsensitive(name, tx)
|
||||
|
||||
if (teamWithName) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
await TeamService.update(id, { name }, tx)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTeamUseCase(
|
||||
id: string,
|
||||
): Promise<TeamUseCaseResult> {
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const team = await TeamService.findById(id, tx)
|
||||
|
||||
if (!team) {
|
||||
return teamError({ id: ["Team not found"] })
|
||||
}
|
||||
|
||||
await TeamService.delete(id, tx)
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
return teamError({ name: ["Team already exists"] })
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,19 @@ export async function createTestPerson(
|
||||
})
|
||||
}
|
||||
|
||||
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(
|
||||
prisma: PrismaClient,
|
||||
overrides: Partial<{
|
||||
|
||||
@@ -18,6 +18,7 @@ const TABLES_TO_TRUNCATE = [
|
||||
"Asset",
|
||||
"Item",
|
||||
"Category",
|
||||
"Team",
|
||||
"Person",
|
||||
"User",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import type { PrismaClient } from "@/generated/prisma/client"
|
||||
import { createTestPerson, createTestTeam } from "../helpers/factories"
|
||||
import {
|
||||
resetIntegrationTestDatabase,
|
||||
startIntegrationTestDatabase,
|
||||
stopIntegrationTestDatabase,
|
||||
} from "../helpers/test-db"
|
||||
|
||||
let prisma: PrismaClient
|
||||
let createTeamUseCase: typeof import("@/use-cases/team.use-cases").createTeamUseCase
|
||||
let updateTeamUseCase: typeof import("@/use-cases/team.use-cases").updateTeamUseCase
|
||||
let deleteTeamUseCase: typeof import("@/use-cases/team.use-cases").deleteTeamUseCase
|
||||
let listTeamsUseCase: typeof import("@/use-cases/team.use-cases").listTeamsUseCase
|
||||
let TeamService: typeof import("@/services/team.service").TeamService
|
||||
|
||||
beforeAll(async () => {
|
||||
await startIntegrationTestDatabase()
|
||||
|
||||
const prismaModule = await import("@/lib/prisma")
|
||||
const teamUseCases = await import("@/use-cases/team.use-cases")
|
||||
const teamService = await import("@/services/team.service")
|
||||
|
||||
prisma = prismaModule.prisma
|
||||
createTeamUseCase = teamUseCases.createTeamUseCase
|
||||
updateTeamUseCase = teamUseCases.updateTeamUseCase
|
||||
deleteTeamUseCase = teamUseCases.deleteTeamUseCase
|
||||
listTeamsUseCase = teamUseCases.listTeamsUseCase
|
||||
TeamService = teamService.TeamService
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetIntegrationTestDatabase(prisma)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma?.$disconnect()
|
||||
await stopIntegrationTestDatabase()
|
||||
})
|
||||
|
||||
describe("team use-cases", () => {
|
||||
it("creates a team and rejects duplicate names", async () => {
|
||||
expect(await createTeamUseCase({ name: "Engineering" })).toEqual({
|
||||
success: true,
|
||||
})
|
||||
|
||||
expect(
|
||||
await prisma.team.findFirst({ where: { name: "Engineering" } }),
|
||||
).toMatchObject({ name: "Engineering" })
|
||||
|
||||
expect(await createTeamUseCase({ name: "Engineering" })).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team already exists"] },
|
||||
})
|
||||
|
||||
expect(await prisma.team.count()).toBe(1)
|
||||
})
|
||||
|
||||
it("rejects duplicate names case-insensitively and trims whitespace", async () => {
|
||||
await createTestTeam(prisma, { name: "Engineering" })
|
||||
|
||||
expect(await createTeamUseCase({ name: "engineering" })).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team already exists"] },
|
||||
})
|
||||
|
||||
expect(await createTeamUseCase({ name: " ENGINEERING " })).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team already exists"] },
|
||||
})
|
||||
|
||||
expect(await prisma.team.count()).toBe(1)
|
||||
})
|
||||
|
||||
it("trims the name before saving", async () => {
|
||||
await createTeamUseCase({ name: " Engineering " })
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: { name: "Engineering" },
|
||||
})
|
||||
|
||||
expect(team).not.toBeNull()
|
||||
expect(team?.name).toBe("Engineering")
|
||||
})
|
||||
|
||||
it("updates a team and rejects unchanged or duplicate names", async () => {
|
||||
const team = await createTestTeam(prisma, { name: "Peripherals" })
|
||||
const other = await createTestTeam(prisma, { name: "Networking" })
|
||||
|
||||
expect(
|
||||
await updateTeamUseCase({ id: team.id, name: "Accessories" }),
|
||||
).toEqual({ success: true })
|
||||
|
||||
expect(
|
||||
await prisma.team.findUniqueOrThrow({ where: { id: team.id } }),
|
||||
).toMatchObject({ name: "Accessories" })
|
||||
|
||||
expect(
|
||||
await updateTeamUseCase({ id: team.id, name: "Accessories" }),
|
||||
).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team name is the same"] },
|
||||
})
|
||||
|
||||
expect(
|
||||
await updateTeamUseCase({ id: team.id, name: other.name }),
|
||||
).toEqual({
|
||||
success: false,
|
||||
errors: { name: ["Team already exists"] },
|
||||
})
|
||||
|
||||
expect(
|
||||
await prisma.team.findUniqueOrThrow({ where: { id: team.id } }),
|
||||
).toMatchObject({ name: "Accessories" })
|
||||
})
|
||||
|
||||
it("returns not found when updating a missing team", async () => {
|
||||
expect(
|
||||
await updateTeamUseCase({
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
name: "Ghost",
|
||||
}),
|
||||
).toEqual({
|
||||
success: false,
|
||||
errors: { id: ["Team not found"] },
|
||||
})
|
||||
})
|
||||
|
||||
it("hard deletes a team", async () => {
|
||||
const team = await createTestTeam(prisma, { name: "Legacy" })
|
||||
|
||||
expect(await deleteTeamUseCase(team.id)).toEqual({ success: true })
|
||||
|
||||
expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull()
|
||||
})
|
||||
|
||||
it("returns not found when deleting a missing team", async () => {
|
||||
expect(
|
||||
await deleteTeamUseCase("00000000-0000-0000-0000-000000000000"),
|
||||
).toEqual({
|
||||
success: false,
|
||||
errors: { id: ["Team not found"] },
|
||||
})
|
||||
})
|
||||
|
||||
it("nulls linked Person.teamId when a team is deleted", async () => {
|
||||
const team = await createTestTeam(prisma, { name: "Assigned" })
|
||||
const person = await createTestPerson(prisma)
|
||||
|
||||
await prisma.person.update({
|
||||
where: { id: person.id },
|
||||
data: { teamId: team.id },
|
||||
})
|
||||
|
||||
expect(await deleteTeamUseCase(team.id)).toEqual({ success: true })
|
||||
|
||||
const updatedPerson = await prisma.person.findUnique({
|
||||
where: { id: person.id },
|
||||
})
|
||||
|
||||
expect(updatedPerson).not.toBeNull()
|
||||
expect(updatedPerson?.teamId).toBeNull()
|
||||
})
|
||||
|
||||
it("lists all teams ordered by name", async () => {
|
||||
await createTestTeam(prisma, { name: "Beta" })
|
||||
await createTestTeam(prisma, { name: "Alpha" })
|
||||
await createTestTeam(prisma, { name: "Gamma" })
|
||||
|
||||
const teams = await listTeamsUseCase()
|
||||
|
||||
expect(teams).toHaveLength(3)
|
||||
expect(teams.map((team) => team.name)).toEqual(["Alpha", "Beta", "Gamma"])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user