diff --git a/jest.config.js b/jest.config.js index 3d14f5e..291e8c5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,9 +1,10 @@ /** @type {import('jest').Config} */ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - roots: ['/tests'], - testMatch: ['**/*.test.ts'], - collectCoverageFrom: ['src/**/*.ts', '!src/index.ts'], - coverageDirectory: 'coverage', -}; \ No newline at end of file + preset: "ts-jest", + testEnvironment: "node", + roots: ["/tests"], + testMatch: ["**/*.test.ts"], + collectCoverageFrom: ["src/**/*.ts", "!src/index.ts"], + coverageDirectory: "coverage", + setupFiles: ["/tests/setup.ts"], +}; diff --git a/package.json b/package.json index 9c8ba91..849bd99 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "type-check": "tsc --noEmit", "validate": "npm run type-check && npm run lint && npm run format:check", "test": "jest --passWithNoTests", - "test:watch": "jest --watch", + "unit-tests": "jest --testPathPattern=unit --passWithNoTests", + "integration-tests": "jest --testPathPattern=integration --passWithNoTests", + "test:watch": "jest --watch --testPathPattern=unit", "test:coverage": "jest --coverage --passWithNoTests --runInBand", "prisma:generate": "prisma generate --schema prisma", "prisma:push": "prisma db push --schema prisma", diff --git a/prisma/models/project.prisma b/prisma/models/project.prisma index 50c7e62..1c1cf63 100644 --- a/prisma/models/project.prisma +++ b/prisma/models/project.prisma @@ -15,7 +15,7 @@ model Project { scanBatches ScanBatch[] @relation("ProjectScanBatches") treeScans TreeScan[] @relation("ProjectTreeScans") -userProjectRoles UserProjectRole[] + userProjectRoles UserProjectRole[] @@index([countryId]) @@index([adminLocationId]) diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index cf77849..225910e 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -37,7 +37,7 @@ model User { correctedTreeScans TreeScan[] @relation("CorrectedTreeScans") treeScanAudits TreeScanAudit[] @relation("TreeScanAuditChangedBy") -userProjectRoles UserProjectRole[] @relation("UserProjectRoles") + userProjectRoles UserProjectRole[] @relation("UserProjectRoles") assignedProjectRoles UserProjectRole[] @relation("AssignedByUser") @@index([roleId]) diff --git a/src/modules/user-project-roles/index.ts b/src/modules/user-project-roles/index.ts new file mode 100644 index 0000000..73ce6bd --- /dev/null +++ b/src/modules/user-project-roles/index.ts @@ -0,0 +1 @@ +export { default as userProjectRolesRoutes } from "./userProjectRole.routes"; diff --git a/src/modules/user-project-roles/userProjectRole.controller.ts b/src/modules/user-project-roles/userProjectRole.controller.ts new file mode 100644 index 0000000..7b45706 --- /dev/null +++ b/src/modules/user-project-roles/userProjectRole.controller.ts @@ -0,0 +1,68 @@ +import type { NextFunction, Request, Response } from "express"; +import { + userProjectRoleService, + type CreateUserProjectRoleInput, +} from "./userProjectRole.service"; + +type AuthenticatedUser = { + id: number; + role: string; +}; + +export class UserProjectRoleController { + async getRoles(req: Request, res: Response, next: NextFunction) { + try { + const user = req.user as unknown as AuthenticatedUser; + + const roles = await userProjectRoleService.getAssignments( + user.id, + user.role, + ); + + return res.status(200).json({ + success: true, + data: roles, + }); + } catch (error) { + return next(error); + } + } + + async createUserProjectRole(req: Request, res: Response, next: NextFunction) { + try { + const payload = req.body as CreateUserProjectRoleInput; + + const role = await userProjectRoleService.createUserProjectRole(payload); + + return res.status(201).json({ + success: true, + data: role, + }); + } catch (error) { + return next(error); + } + } + + async deleteUserProjectRole(req: Request, res: Response, next: NextFunction) { + try { + const userId = Number(req.params.user_id); + const projectId = Number(req.params.project_id); + const roleId = Number(req.params.role_id); + + const result = await userProjectRoleService.deleteUserProjectRole( + userId, + projectId, + roleId, + ); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + return next(error); + } + } +} + +export const userProjectRoleController = new UserProjectRoleController(); diff --git a/src/modules/user-project-roles/userProjectRole.routes.ts b/src/modules/user-project-roles/userProjectRole.routes.ts new file mode 100644 index 0000000..6214710 --- /dev/null +++ b/src/modules/user-project-roles/userProjectRole.routes.ts @@ -0,0 +1,144 @@ +import { Router } from "express"; +import { authMiddleware } from "../../middleware/auth.middleware"; +import { roleMiddleware } from "../../middleware/role.middleware"; +import { userProjectRoleController } from "./userProjectRole.controller"; + +const router = Router(); + +/** + * @swagger + * tags: + * - name: User Project Roles + * description: Endpoints for managing user roles within projects + */ + +/** + * @swagger + * /user-project-roles: + * get: + * summary: Retrieve user project role assignments + * tags: [User Project Roles] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: User project roles retrieved successfully + * 401: + * description: Authentication required + * 403: + * description: Insufficient permissions + */ +router.get( + "/", + authMiddleware, + roleMiddleware(["ADMIN", "MANAGER"]), + (req, res, next) => { + void userProjectRoleController.getRoles(req, res, next); + }, +); + +/** + * @swagger + * /user-project-roles: + * post: + * summary: Assign a role to a user within a project + * tags: [User Project Roles] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userId + * - projectId + * - roleId + * - assignedBy + * properties: + * userId: + * type: integer + * projectId: + * type: integer + * roleId: + * type: integer + * assignedBy: + * type: integer + * example: + * userId: 1 + * projectId: 10 + * roleId: 2 + * assignedBy: 4 + * responses: + * 201: + * description: User project role assigned successfully + * 400: + * description: Invalid request body + * 401: + * description: Authentication required + * 403: + * description: Insufficient permissions + * 404: + * description: User, project, or role not found + * 409: + * description: Role assignment already exists + */ +router.post( + "/", + authMiddleware, + roleMiddleware(["ADMIN"]), + (req, res, next) => { + void userProjectRoleController.createUserProjectRole(req, res, next); + }, +); + +/** + * @swagger + * /user-project-roles/{user_id}/{project_id}/{role_id}: + * delete: + * summary: Remove a role assignment from a user within a project + * tags: [User Project Roles] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: user_id + * required: true + * schema: + * type: integer + * description: User ID + * - in: path + * name: project_id + * required: true + * schema: + * type: integer + * description: Project ID + * - in: path + * name: role_id + * required: true + * schema: + * type: integer + * description: Role ID + * responses: + * 200: + * description: User project role removed successfully + * 400: + * description: Invalid user, project, or role ID + * 401: + * description: Authentication required + * 403: + * description: Insufficient permissions + * 404: + * description: Role assignment not found + */ +router.delete( + "/:user_id/:project_id/:role_id", + authMiddleware, + roleMiddleware(["ADMIN"]), + (req, res, next) => { + void userProjectRoleController.deleteUserProjectRole(req, res, next); + }, +); + +export default router; diff --git a/src/modules/user-project-roles/userProjectRole.service.ts b/src/modules/user-project-roles/userProjectRole.service.ts new file mode 100644 index 0000000..5a25cd1 --- /dev/null +++ b/src/modules/user-project-roles/userProjectRole.service.ts @@ -0,0 +1,203 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "../../lib/prisma"; +import { AppError } from "../../middleware/errorHandler"; +import { ERROR_CODES } from "../../utils/errorCodes"; + +export type CreateUserProjectRoleInput = { + userId: number; + projectId: number; + roleId: number; + assignedBy: number; +}; + +const roleAssignmentInclude = { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + project: { + select: { + id: true, + name: true, + isActive: true, + }, + }, + role: { + select: { + id: true, + name: true, + }, + }, + assignedByUser: { + select: { + id: true, + name: true, + }, + }, +}; + +const isPositiveInt = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value > 0; + +const assertIds = (...ids: number[]) => { + if (!ids.every(isPositiveInt)) { + throw new AppError(400, "Invalid ids", ERROR_CODES.VAL_002); + } +}; + +const ensureUserExists = async (userId: number) => { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + if (!user) { + throw new AppError(404, "User not found", ERROR_CODES.DATA_001); + } +}; + +const ensureProjectExists = async (projectId: number) => { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { id: true }, + }); + if (!project) { + throw new AppError(404, "Project not found", ERROR_CODES.DATA_001); + } +}; + +const ensureRoleExists = async (roleId: number) => { + const role = await prisma.role.findUnique({ + where: { id: roleId }, + select: { id: true }, + }); + if (!role) { + throw new AppError(404, "Role not found", ERROR_CODES.DATA_001); + } +}; + +export class UserProjectRoleService { + async getAssignments(userId: number, role: string) { + try { + if (role === "ADMIN") { + return await prisma.userProjectRole.findMany({ + include: roleAssignmentInclude, + orderBy: [{ projectId: "asc" }, { userId: "asc" }, { roleId: "asc" }], + }); + } + + const managerProjects = await prisma.userProject.findMany({ + where: { userId }, + select: { projectId: true }, + }); + + const managerProjectIds = managerProjects.map((p) => p.projectId); + + return await prisma.userProjectRole.findMany({ + where: { + projectId: { + in: managerProjectIds, + }, + }, + include: roleAssignmentInclude, + orderBy: [{ projectId: "asc" }, { userId: "asc" }, { roleId: "asc" }], + }); + } catch { + throw new AppError(500, ERROR_CODES.SYS_002, ERROR_CODES.SYS_002); + } + } + + async createUserProjectRole(data: CreateUserProjectRoleInput) { + assertIds(data.userId, data.projectId, data.roleId, data.assignedBy); + + try { + await ensureUserExists(data.userId); + await ensureProjectExists(data.projectId); + await ensureRoleExists(data.roleId); + + const existing = await prisma.userProjectRole.findUnique({ + where: { + userId_projectId_roleId: { + userId: data.userId, + projectId: data.projectId, + roleId: data.roleId, + }, + }, + }); + + if (existing) { + throw new AppError(409, ERROR_CODES.DATA_002, ERROR_CODES.DATA_002); + } + + return await prisma.userProjectRole.create({ + data, + include: roleAssignmentInclude, + }); + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + throw new AppError(409, ERROR_CODES.DATA_002, ERROR_CODES.DATA_002); + } + + throw new AppError(500, ERROR_CODES.SYS_002, ERROR_CODES.SYS_002); + } + } + + async deleteUserProjectRole( + userId: number, + projectId: number, + roleId: number, + ) { + assertIds(userId, projectId, roleId); + + try { + const existing = await prisma.userProjectRole.findUnique({ + where: { + userId_projectId_roleId: { + userId, + projectId, + roleId, + }, + }, + }); + + if (!existing) { + throw new AppError( + 404, + "User project role not found", + ERROR_CODES.DATA_001, + ); + } + + await prisma.userProjectRole.delete({ + where: { + userId_projectId_roleId: { + userId, + projectId, + roleId, + }, + }, + }); + + return { + message: "User project role removed successfully", + }; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError(500, ERROR_CODES.SYS_002, ERROR_CODES.SYS_002); + } + } +} + +export const userProjectRoleService = new UserProjectRoleService(); diff --git a/src/routes/index.ts b/src/routes/index.ts index 3ad61b6..3ac13dc 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,7 @@ import { projectManagementRoutes } from "../modules/project-management"; import { localizationRoutes } from "../modules/localization"; import { adoptersRouter } from "../modules/adopters"; import { userProjectAssignmentRoutes } from "../modules/user-project-assignment"; +import { userProjectRolesRoutes } from "../modules/user-project-roles"; import { partnersRoutes } from "../modules/partners"; import treeScansRoutes from "../modules/tree-scans"; @@ -22,6 +23,7 @@ router.use("/tree-types", treeTypesRoutes); router.use("/projects", projectManagementRoutes); router.use("/localized-strings", localizationRoutes); router.use("/user-projects", userProjectAssignmentRoutes); +router.use("/user-project-roles", userProjectRolesRoutes); router.use("/project-tree-types", projectTreeTypesRoutes); router.use("/partners", partnersRoutes); diff --git a/tests/integration/userProjectRole.test.ts b/tests/integration/userProjectRole.test.ts new file mode 100644 index 0000000..b67b5ab --- /dev/null +++ b/tests/integration/userProjectRole.test.ts @@ -0,0 +1,338 @@ +import "dotenv/config"; +import request from "supertest"; +import { PrismaClient } from "@prisma/client"; +import app from "../../src/app"; + +const prisma = new PrismaClient(); + +const TOKENS = { + ADMIN: process.env.AUTH_DEV_ADMIN_TOKEN!, + MANAGER: process.env.AUTH_DEV_MANAGER_TOKEN!, + INSPECTOR: process.env.AUTH_DEV_INSPECTOR_TOKEN!, + FARMER: process.env.AUTH_DEV_FARMER_TOKEN!, +}; + +describe("User Project Role Integration Tests", () => { + let userId: number; + let projectId: number; + let roleId: number; + let countryId: number; + let locationId: number; + + beforeAll(async () => { + await prisma.userProjectRole.deleteMany(); + await prisma.user.deleteMany({ + where: { email: "role-test-user@test.com" }, + }); + await prisma.project.deleteMany({ + where: { name: { startsWith: "Role Test Project" } }, + }); + await prisma.location.deleteMany({ + where: { name: "Role Test Location" }, + }); + await prisma.country.deleteMany({ + where: { iso2: "RX" }, + }); + + const role = await prisma.role.upsert({ + where: { name: "FARMER" }, + update: {}, + create: { name: "FARMER" }, + }); + roleId = role.id; + + const country = await prisma.country.create({ + data: { + name: "Role Test Country", + iso2: "RX", + iso3: "RXT", + }, + }); + countryId = country.id; + + const location = await prisma.location.create({ + data: { + countryId, + level: 1, + name: "Role Test Location", + }, + }); + locationId = location.id; + + const user = await prisma.user.create({ + data: { + name: "Role Test User", + email: "role-test-user@test.com", + roleId, + }, + }); + userId = user.id; + }); + + beforeEach(async () => { + await prisma.userProjectRole.deleteMany({ + where: { userId }, + }); + + await prisma.project.deleteMany({ + where: { name: { startsWith: "Role Test Project" } }, + }); + + const project = await prisma.project.create({ + data: { + name: "Role Test Project", + description: "Project used for user project role endpoint tests", + countryId, + adminLocationId: locationId, + isActive: true, + }, + }); + + projectId = project.id; + }); + + afterAll(async () => { + await prisma.userProjectRole.deleteMany({ + where: { userId }, + }); + + await prisma.user.deleteMany({ + where: { email: "role-test-user@test.com" }, + }); + + await prisma.project.deleteMany({ + where: { name: { startsWith: "Role Test Project" } }, + }); + + await prisma.location.deleteMany({ + where: { id: locationId }, + }); + + await prisma.country.deleteMany({ + where: { id: countryId }, + }); + + await prisma.$disconnect(); + }); + + describe("GET /user-project-roles", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app).get("/user-project-roles"); + + expect(response.status).toBe(401); + }); + + it("should return 200 for ADMIN token", async () => { + await prisma.userProjectRole.create({ + data: { userId, projectId, roleId, assignedBy: userId }, + }); + + const response = await request(app) + .get("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(Array.isArray(response.body.data)).toBe(true); + }); + + it("should return 200 for MANAGER token", async () => { + const response = await request(app) + .get("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it("should return 403 for INSPECTOR token", async () => { + const response = await request(app) + .get("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(response.status).toBe(403); + }); + + it("should return 403 for FARMER token", async () => { + const response = await request(app) + .get("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(response.status).toBe(403); + }); + }); + + describe("POST /user-project-roles", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app) + .post("/user-project-roles") + .send({ userId, projectId, roleId, assignedBy: userId }); + + expect(response.status).toBe(401); + }); + + it("should return 201 for ADMIN token and create a role assignment", async () => { + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ userId, projectId, roleId, assignedBy: userId }); + + expect(response.status).toBe(201); + expect(response.body.success).toBe(true); + expect(response.body.data.userId).toBe(userId); + expect(response.body.data.projectId).toBe(projectId); + expect(response.body.data.roleId).toBe(roleId); + }); + + it("should return 403 for MANAGER token", async () => { + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.MANAGER}`) + .send({ userId, projectId, roleId, assignedBy: userId }); + + expect(response.status).toBe(403); + }); + + it("should return 403 for INSPECTOR token", async () => { + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`) + .send({ userId, projectId, roleId, assignedBy: userId }); + + expect(response.status).toBe(403); + }); + + it("should return 403 for FARMER token", async () => { + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.FARMER}`) + .send({ userId, projectId, roleId, assignedBy: userId }); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid payload", async () => { + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ userId: 0, projectId, roleId, assignedBy: userId }); + + expect(response.status).toBe(400); + }); + + it("should return 404 when user does not exist", async () => { + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ userId: 999999, projectId, roleId, assignedBy: userId }); + + expect(response.status).toBe(404); + }); + + it("should return 404 when project does not exist", async () => { + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ userId, projectId: 999999, roleId, assignedBy: userId }); + + expect(response.status).toBe(404); + }); + + it("should return 404 when role does not exist", async () => { + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ userId, projectId, roleId: 999999, assignedBy: userId }); + + expect(response.status).toBe(404); + }); + + it("should return 409 for duplicate role assignment", async () => { + await prisma.userProjectRole.create({ + data: { userId, projectId, roleId, assignedBy: userId }, + }); + + const response = await request(app) + .post("/user-project-roles") + .set("Authorization", `Bearer ${TOKENS.ADMIN}`) + .send({ userId, projectId, roleId, assignedBy: userId }); + + expect(response.status).toBe(409); + }); + }); + + describe("DELETE /user-project-roles/:user_id/:project_id/:role_id", () => { + it("should return 401 when no token is provided", async () => { + const response = await request(app).delete( + `/user-project-roles/${userId}/${projectId}/${roleId}`, + ); + + expect(response.status).toBe(401); + }); + + it("should return 200 for ADMIN token and remove the role assignment", async () => { + await prisma.userProjectRole.create({ + data: { userId, projectId, roleId, assignedBy: userId }, + }); + + const response = await request(app) + .delete(`/user-project-roles/${userId}/${projectId}/${roleId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + + const assignment = await prisma.userProjectRole.findUnique({ + where: { + userId_projectId_roleId: { + userId, + projectId, + roleId, + }, + }, + }); + + expect(assignment).toBeNull(); + }); + + it("should return 403 for MANAGER token", async () => { + const response = await request(app) + .delete(`/user-project-roles/${userId}/${projectId}/${roleId}`) + .set("Authorization", `Bearer ${TOKENS.MANAGER}`); + + expect(response.status).toBe(403); + }); + + it("should return 403 for INSPECTOR token", async () => { + const response = await request(app) + .delete(`/user-project-roles/${userId}/${projectId}/${roleId}`) + .set("Authorization", `Bearer ${TOKENS.INSPECTOR}`); + + expect(response.status).toBe(403); + }); + + it("should return 403 for FARMER token", async () => { + const response = await request(app) + .delete(`/user-project-roles/${userId}/${projectId}/${roleId}`) + .set("Authorization", `Bearer ${TOKENS.FARMER}`); + + expect(response.status).toBe(403); + }); + + it("should return 400 for invalid path params", async () => { + const response = await request(app) + .delete(`/user-project-roles/abc/${projectId}/${roleId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(400); + }); + + it("should return 404 when role assignment does not exist", async () => { + const response = await request(app) + .delete(`/user-project-roles/${userId}/${projectId}/${roleId}`) + .set("Authorization", `Bearer ${TOKENS.ADMIN}`); + + expect(response.status).toBe(404); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..8fb41f5 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,15 @@ +process.env.NODE_ENV = "development"; +process.env.JWT_SECRET = "test-secret-key-minimum-32-characters-long"; +process.env.JWT_EXPIRES_IN = "24h"; +process.env.AUTH_DEV_MODE = "true"; +process.env.AUTH_DEV_ADMIN_TOKEN = "dev-admin-token"; +process.env.AUTH_DEV_MANAGER_TOKEN = "dev-manager-token"; +process.env.AUTH_DEV_INSPECTOR_TOKEN = "dev-inspector-token"; +process.env.AUTH_DEV_FARMER_TOKEN = "dev-farmer-token"; +process.env.AUTH_DEV_DEVELOPER_TOKEN = "dev-developer-token"; +process.env.RATE_LIMIT_WINDOW_MS = "900000"; +process.env.RATE_LIMIT_MAX = "100"; +process.env.LOG_TO_FILE = "false"; +process.env.PORT = "3000"; +process.env.DATABASE_URL = + "postgresql://treeo2_user:treeo2_password@localhost:5432/treeo2?schema=public"; diff --git a/tests/unit/userProjectRole.test.ts b/tests/unit/userProjectRole.test.ts new file mode 100644 index 0000000..afd5014 --- /dev/null +++ b/tests/unit/userProjectRole.test.ts @@ -0,0 +1,285 @@ +import { ERROR_CODES } from "../../src/utils/errorCodes"; +import { UserProjectRoleService } from "../../src/modules/user-project-roles/userProjectRole.service"; + +jest.mock("@prisma/client", () => { + const mockPrisma = { + userProjectRole: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + userProject: { + findMany: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + project: { + findUnique: jest.fn(), + }, + role: { + findUnique: jest.fn(), + }, + }; + + class PrismaClientKnownRequestError extends Error { + code: string; + + constructor(message: string, options: { code: string }) { + super(message); + this.code = options.code; + this.name = "PrismaClientKnownRequestError"; + } + } + + return { + PrismaClient: jest.fn(() => mockPrisma), + Prisma: { + PrismaClientKnownRequestError, + }, + __mockPrisma: mockPrisma, + }; +}); + +const { __mockPrisma: mockPrisma } = jest.requireMock("@prisma/client"); + +describe("UserProjectRoleService", () => { + let service: UserProjectRoleService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new UserProjectRoleService(); + }); + + describe("getAssignments", () => { + it("returns all role assignments ordered by project, user and role for ADMIN", async () => { + const assignments = [{ userId: 1, projectId: 2, roleId: 3 }]; + + mockPrisma.userProjectRole.findMany.mockResolvedValue(assignments); + + const result = await service.getAssignments(1, "ADMIN"); + + expect(mockPrisma.userProjectRole.findMany).toHaveBeenCalledWith({ + include: expect.any(Object), + orderBy: [{ projectId: "asc" }, { userId: "asc" }, { roleId: "asc" }], + }); + expect(result).toEqual(assignments); + }); + + it("returns scoped role assignments for MANAGER", async () => { + const managerProjects = [{ projectId: 2 }]; + const assignments = [{ userId: 3, projectId: 2, roleId: 1 }]; + + mockPrisma.userProject.findMany.mockResolvedValue(managerProjects); + mockPrisma.userProjectRole.findMany.mockResolvedValue(assignments); + + const result = await service.getAssignments(1, "MANAGER"); + + expect(mockPrisma.userProject.findMany).toHaveBeenCalledWith({ + where: { userId: 1 }, + select: { projectId: true }, + }); + + expect(mockPrisma.userProjectRole.findMany).toHaveBeenCalledWith({ + where: { + projectId: { + in: [2], + }, + }, + include: expect.any(Object), + orderBy: [{ projectId: "asc" }, { userId: "asc" }, { roleId: "asc" }], + }); + + expect(result).toEqual(assignments); + }); + + it("throws SYS_002 when fetching assignments fails", async () => { + mockPrisma.userProjectRole.findMany.mockRejectedValue( + new Error("DB down"), + ); + + await expect(service.getAssignments(1, "ADMIN")).rejects.toMatchObject({ + statusCode: 500, + code: ERROR_CODES.SYS_002, + }); + }); + }); + + describe("createUserProjectRole", () => { + it("creates a role assignment with valid input", async () => { + const record = { userId: 1, projectId: 2, roleId: 3, assignedBy: 1 }; + + mockPrisma.user.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.project.findUnique.mockResolvedValue({ id: 2 }); + mockPrisma.role.findUnique.mockResolvedValue({ id: 3 }); + mockPrisma.userProjectRole.findUnique.mockResolvedValue(null); + mockPrisma.userProjectRole.create.mockResolvedValue(record); + + const result = await service.createUserProjectRole({ + userId: 1, + projectId: 2, + roleId: 3, + assignedBy: 1, + }); + + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: 1 }, + select: { id: true }, + }); + expect(mockPrisma.project.findUnique).toHaveBeenCalledWith({ + where: { id: 2 }, + select: { id: true }, + }); + expect(mockPrisma.role.findUnique).toHaveBeenCalledWith({ + where: { id: 3 }, + select: { id: true }, + }); + expect(mockPrisma.userProjectRole.create).toHaveBeenCalledWith({ + data: { userId: 1, projectId: 2, roleId: 3, assignedBy: 1 }, + include: expect.any(Object), + }); + expect(result).toEqual(record); + }); + + it("throws VAL_002 for invalid ids", async () => { + await expect( + service.createUserProjectRole({ + userId: 0, + projectId: 1, + roleId: 1, + assignedBy: 1, + }), + ).rejects.toMatchObject({ + statusCode: 400, + code: ERROR_CODES.VAL_002, + }); + }); + + it("throws DATA_001 when user is missing", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect( + service.createUserProjectRole({ + userId: 1, + projectId: 2, + roleId: 3, + assignedBy: 1, + }), + ).rejects.toMatchObject({ + statusCode: 404, + code: ERROR_CODES.DATA_001, + message: "User not found", + }); + }); + + it("throws DATA_001 when project is missing", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.project.findUnique.mockResolvedValue(null); + + await expect( + service.createUserProjectRole({ + userId: 1, + projectId: 2, + roleId: 3, + assignedBy: 1, + }), + ).rejects.toMatchObject({ + statusCode: 404, + code: ERROR_CODES.DATA_001, + message: "Project not found", + }); + }); + + it("throws DATA_001 when role is missing", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.project.findUnique.mockResolvedValue({ id: 2 }); + mockPrisma.role.findUnique.mockResolvedValue(null); + + await expect( + service.createUserProjectRole({ + userId: 1, + projectId: 2, + roleId: 3, + assignedBy: 1, + }), + ).rejects.toMatchObject({ + statusCode: 404, + code: ERROR_CODES.DATA_001, + message: "Role not found", + }); + }); + + it("throws DATA_002 when role assignment already exists", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ id: 1 }); + mockPrisma.project.findUnique.mockResolvedValue({ id: 2 }); + mockPrisma.role.findUnique.mockResolvedValue({ id: 3 }); + mockPrisma.userProjectRole.findUnique.mockResolvedValue({ + userId: 1, + projectId: 2, + roleId: 3, + }); + + await expect( + service.createUserProjectRole({ + userId: 1, + projectId: 2, + roleId: 3, + assignedBy: 1, + }), + ).rejects.toMatchObject({ + statusCode: 409, + code: ERROR_CODES.DATA_002, + }); + }); + }); + + describe("deleteUserProjectRole", () => { + it("removes an existing role assignment", async () => { + mockPrisma.userProjectRole.findUnique.mockResolvedValue({ + userId: 1, + projectId: 2, + roleId: 3, + }); + mockPrisma.userProjectRole.delete.mockResolvedValue({ + userId: 1, + projectId: 2, + roleId: 3, + }); + + const result = await service.deleteUserProjectRole(1, 2, 3); + + expect(mockPrisma.userProjectRole.delete).toHaveBeenCalledWith({ + where: { + userId_projectId_roleId: { + userId: 1, + projectId: 2, + roleId: 3, + }, + }, + }); + expect(result.message).toBe("User project role removed successfully"); + }); + + it("throws VAL_002 for invalid path ids", async () => { + await expect( + service.deleteUserProjectRole(Number.NaN, 1, 1), + ).rejects.toMatchObject({ + statusCode: 400, + code: ERROR_CODES.VAL_002, + }); + }); + + it("throws DATA_001 when role assignment is missing", async () => { + mockPrisma.userProjectRole.findUnique.mockResolvedValue(null); + + await expect( + service.deleteUserProjectRole(1, 2, 3), + ).rejects.toMatchObject({ + statusCode: 404, + code: ERROR_CODES.DATA_001, + message: "User project role not found", + }); + }); + }); +});