feat(teams): add team service and use-cases with integration tests

This commit is contained in:
2026-06-26 00:03:01 +02:00
parent f88d831f4c
commit 5d303bdb7e
7 changed files with 375 additions and 0 deletions
+57
View File
@@ -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 } })
},
}
+1
View File
@@ -6,4 +6,5 @@ export * from "./item"
export * from "./movement"
export * from "./paginate"
export * from "./person"
export * from "./team"
export * from "./user"
+5
View File
@@ -0,0 +1,5 @@
import type { Team as PrismaTeam } from "@/generated/prisma/client"
export type Team = PrismaTeam
export type TeamSummary = Pick<Team, "id" | "name">
+123
View File
@@ -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
}
}
+13
View File
@@ -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<{
+1
View File
@@ -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"])
})
})