diff --git a/src/middleware/authMiddleware.ts b/src/middleware/authMiddleware.ts index 5a0384a..45e2db3 100644 --- a/src/middleware/authMiddleware.ts +++ b/src/middleware/authMiddleware.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import jwt from "jsonwebtoken"; -import { PrismaUserRepository } from "../modules/user/repositories/PrismaUserRepository"; +import { container, USER_REPOSITORY } from "../shared/infrastructure/container"; +import { IUserRepository } from "../modules/user/domain/interfaces/IUserRepository"; import { AuthenticatedRequest, DecodedUser, @@ -8,7 +9,7 @@ import { } from "../types/auth.types"; const SECRET_KEY = process.env.JWT_SECRET || "defaultSecret"; -const userRepository = new PrismaUserRepository(); +const userRepository = container[USER_REPOSITORY] as IUserRepository; export const authMiddleware = async ( req: Request, diff --git a/src/modules/auth/presentation/controllers/Auth.controller.ts b/src/modules/auth/presentation/controllers/Auth.controller.ts index 1ba7a6d..08c0dfa 100644 --- a/src/modules/auth/presentation/controllers/Auth.controller.ts +++ b/src/modules/auth/presentation/controllers/Auth.controller.ts @@ -14,14 +14,15 @@ import { } from "../../dto/wallet-validation.dto"; // Use cases -import { PrismaUserRepository } from "../../../user/repositories/PrismaUserRepository"; +import { container, USER_REPOSITORY } from "../../../shared/infrastructure/container"; +import { IUserRepository } from "../../../user/domain/interfaces/IUserRepository"; import { SendVerificationEmailUseCase } from "../../use-cases/send-verification-email.usecase"; import { ResendVerificationEmailUseCase } from "../../use-cases/resend-verification-email.usecase"; import { VerifyEmailUseCase } from "../../use-cases/verify-email.usecase"; import { ValidateWalletFormatUseCase } from "../../use-cases/wallet-format-validation.usecase"; import { VerifyWalletUseCase } from "../../use-cases/verify-wallet.usecase"; -const userRepository = new PrismaUserRepository(); +const userRepository = container[USER_REPOSITORY] as IUserRepository; const sendVerificationEmailUseCase = new SendVerificationEmailUseCase( userRepository ); diff --git a/src/modules/user/__tests__/controllers/UserController.int.test.ts b/src/modules/user/__tests__/controllers/UserController.int.test.ts index cd6162c..1e93003 100644 --- a/src/modules/user/__tests__/controllers/UserController.int.test.ts +++ b/src/modules/user/__tests__/controllers/UserController.int.test.ts @@ -3,9 +3,21 @@ import request from "supertest"; import express, { Express } from "express"; import UserController from "../../presentation/controllers/UserController"; -// Mock the UserService -jest.mock("../../../../services/UserService"); -import { UserService } from "../../../../services/UserService"; +// Mock the repository +jest.mock("../../../../shared/infrastructure/container", () => ({ + container: { + [Symbol.for("USER_REPOSITORY")]: { + create: jest.fn(), + findById: jest.fn(), + findByEmail: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + USER_REPOSITORY: Symbol.for("USER_REPOSITORY"), +})); + +import { container, USER_REPOSITORY } from "../../../../shared/infrastructure/container"; // Mock DTOs jest.mock("../../dto/CreateUserDto", () => { @@ -34,14 +46,16 @@ function setupApp(): Express { app.get("/users/:id", controller.getUserById.bind(controller)); app.get("/users", controller.getUserByEmail.bind(controller)); app.put("/users/:id", controller.updateUser.bind(controller)); + app.delete("/users/:id", controller.deleteUser.bind(controller)); return app; } -const setupMockUserService = (methods: Partial>) => { - (UserService as jest.Mock).mockImplementation(() => methods); +const setupMockRepository = (methods: Partial>) => { + const mockRepo = container[USER_REPOSITORY] as any; + Object.assign(mockRepo, methods); }; -describe("UserController", () => { +describe("UserController Integration Tests", () => { let app: Express; beforeEach(() => { @@ -51,78 +65,134 @@ describe("UserController", () => { describe("POST /users", () => { it("should create a user and return 201", async () => { - const mockUser = { id: "1", email: "test@example.com" }; - setupMockUserService({ - createUser: jest.fn().mockResolvedValue(mockUser), + const mockUser = { + id: "1", + email: "test@example.com", + name: "Test User", + lastName: "Test", + wallet: "GABCDEF123456789", + isVerified: false, + createdAt: new Date(), + updatedAt: new Date() + }; + + setupMockRepository({ + create: jest.fn().mockResolvedValue(mockUser), }); + const res = await request(app) .post("/users") - .send({ email: "test@example.com" }); + .send({ + email: "test@example.com", + name: "Test User", + lastName: "Test", + password: "password123", + wallet: "GABCDEF123456789" + }); + expect(res.status).toBe(201); expect(res.body).toEqual(mockUser); }); - it("should handle errors and return 400", async () => { - (UserService as jest.Mock).mockImplementation(() => ({ - createUser: jest.fn().mockRejectedValue(new Error("fail")), - })); + it("should handle conflict errors and return 409", async () => { + setupMockRepository({ + create: jest.fn().mockRejectedValue(new Error("A user with this email or wallet already exists")), + }); + const res = await request(app) .post("/users") - .send({ email: "test@example.com" }); - expect(res.status).toBe(400); - expect(res.body.error).toBe("fail"); + .send({ + email: "test@example.com", + name: "Test User", + lastName: "Test", + password: "password123", + wallet: "GABCDEF123456789" + }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("already exists"); }); - it("should handle validation errors with specific status codes", async () => { - (UserService as jest.Mock).mockImplementation(() => ({ - createUser: jest - .fn() - .mockRejectedValue(new Error("Invalid email format")), - })); + it("should handle validation errors and return 400", async () => { + setupMockRepository({ + create: jest.fn().mockRejectedValue(new Error("Validation error")), + }); + const res = await request(app) .post("/users") - .send({ email: "invalid-email" }); - expect(res.status).toBe(422); - expect(res.body.error).toContain("Ivalid email format"); + .send({ + email: "test@example.com", + name: "Test User", + lastName: "Test", + password: "password123", + wallet: "GABCDEF123456789" + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("validation"); }); }); describe("GET /users/:id", () => { it("should return a user by id", async () => { - const mockUser = { id: "1", email: "test@example.com" }; - (UserService as jest.Mock).mockImplementation(() => ({ - getUserById: jest.fn().mockResolvedValue(mockUser), - })); + const mockUser = { + id: "1", + email: "test@example.com", + name: "Test User", + lastName: "Test", + wallet: "GABCDEF123456789", + isVerified: false, + createdAt: new Date(), + updatedAt: new Date() + }; + + setupMockRepository({ + findById: jest.fn().mockResolvedValue(mockUser), + }); + const res = await request(app).get("/users/1"); expect(res.status).toBe(200); expect(res.body).toEqual(mockUser); }); it("should return 404 if user not found", async () => { - (UserService as jest.Mock).mockImplementation(() => ({ - getUserById: jest.fn().mockResolvedValue(null), - })); + setupMockRepository({ + findById: jest.fn().mockResolvedValue(null), + }); + const res = await request(app).get("/users/1"); expect(res.status).toBe(404); expect(res.body.error).toBe("User not found"); }); - it("should handle errors and return 400", async () => { - (UserService as jest.Mock).mockImplementation(() => ({ - getUserById: jest.fn().mockRejectedValue(new Error("fail")), - })); + it("should handle errors and return 500", async () => { + setupMockRepository({ + findById: jest.fn().mockRejectedValue(new Error("Database error")), + }); + const res = await request(app).get("/users/1"); - expect(res.status).toBe(400); - expect(res.body.error).toBe("fail"); + expect(res.status).toBe(500); + expect(res.body.error).toBe("Internal server error"); }); }); describe("GET /users?email=", () => { it("should return a user by email", async () => { - const mockUser = { id: "1", email: "test@example.com" }; - (UserService as jest.Mock).mockImplementation(() => ({ - getUserByEmail: jest.fn().mockResolvedValue(mockUser), - })); + const mockUser = { + id: "1", + email: "test@example.com", + name: "Test User", + lastName: "Test", + wallet: "GABCDEF123456789", + isVerified: false, + createdAt: new Date(), + updatedAt: new Date() + }; + + setupMockRepository({ + findByEmail: jest.fn().mockResolvedValue(mockUser), + }); + const res = await request(app).get("/users?email=test@example.com"); expect(res.status).toBe(200); expect(res.body).toEqual(mockUser); @@ -135,41 +205,79 @@ describe("UserController", () => { }); it("should return 404 if user not found", async () => { - (UserService as jest.Mock).mockImplementation(() => ({ - getUserByEmail: jest.fn().mockResolvedValue(null), - })); + setupMockRepository({ + findByEmail: jest.fn().mockResolvedValue(null), + }); + const res = await request(app).get("/users?email=test@example.com"); expect(res.status).toBe(404); expect(res.body.error).toBe("User not found"); }); - - it("should handle errors and return 400", async () => { - (UserService as jest.Mock).mockImplementation(() => ({ - getUserByEmail: jest.fn().mockRejectedValue(new Error("fail")), - })); - const res = await request(app).get("/users?email=test@example.com"); - expect(res.status).toBe(400); - expect(res.body.error).toBe("fail"); - }); }); describe("PUT /users/:id", () => { it("should update a user and return 200", async () => { - (UserService as jest.Mock).mockImplementation(() => ({ - updateUser: jest.fn().mockResolvedValue(undefined), - })); - const res = await request(app).put("/users/1").send({ name: "Updated" }); + const mockUpdatedUser = { + id: "1", + email: "test@example.com", + name: "Updated User", + lastName: "Updated", + wallet: "GABCDEF123456789", + isVerified: false, + createdAt: new Date(), + updatedAt: new Date() + }; + + setupMockRepository({ + update: jest.fn().mockResolvedValue(mockUpdatedUser), + }); + + const res = await request(app).put("/users/1").send({ name: "Updated User" }); expect(res.status).toBe(200); expect(res.body.message).toBe("User updated successfully"); + expect(res.body.user).toEqual(mockUpdatedUser); }); - it("should handle errors and return 400", async () => { - (UserService as jest.Mock).mockImplementation(() => ({ - updateUser: jest.fn().mockRejectedValue(new Error("fail")), - })); + it("should handle not found errors and return 404", async () => { + setupMockRepository({ + update: jest.fn().mockRejectedValue(new Error("User not found")), + }); + const res = await request(app).put("/users/1").send({ name: "Updated" }); - expect(res.status).toBe(400); - expect(res.body.error).toBe("fail"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("User not found"); + }); + + it("should handle conflict errors and return 409", async () => { + setupMockRepository({ + update: jest.fn().mockRejectedValue(new Error("A user with this email already exists")), + }); + + const res = await request(app).put("/users/1").send({ email: "conflict@example.com" }); + expect(res.status).toBe(409); + expect(res.body.error).toBe("A user with this email already exists"); + }); + }); + + describe("DELETE /users/:id", () => { + it("should delete a user and return 200", async () => { + setupMockRepository({ + delete: jest.fn().mockResolvedValue(undefined), + }); + + const res = await request(app).delete("/users/1"); + expect(res.status).toBe(200); + expect(res.body.message).toBe("User deleted successfully"); + }); + + it("should handle not found errors and return 404", async () => { + setupMockRepository({ + delete: jest.fn().mockRejectedValue(new Error("User not found")), + }); + + const res = await request(app).delete("/users/1"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("User not found"); }); }); }); diff --git a/src/modules/user/__tests__/repositories/user-prisma.repository.test.ts b/src/modules/user/__tests__/repositories/user-prisma.repository.test.ts new file mode 100644 index 0000000..52a9d6c --- /dev/null +++ b/src/modules/user/__tests__/repositories/user-prisma.repository.test.ts @@ -0,0 +1,359 @@ +import { UserPrismaRepository } from "../../infrastructure/repositories/implementations/user-prisma.repository"; +import { CreateUserData, UpdateUserData } from "../../domain/dto/UserDTO"; +import { PrismaClient, Prisma } from "@prisma/client"; +import { ResourceNotFoundError, ResourceConflictError, ValidationError } from "../../../../shared/application/errors"; + +// Mock Prisma +const mockPrisma = { + user: { + create: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, +} as unknown as PrismaClient; + +describe("UserPrismaRepository", () => { + let repository: UserPrismaRepository; + + beforeEach(() => { + repository = new UserPrismaRepository(mockPrisma); + jest.clearAllMocks(); + }); + + describe("create", () => { + const createUserData: CreateUserData = { + name: "John", + lastName: "Doe", + email: "john@example.com", + password: "hashedPassword", + wallet: "GABCDEF123456789", + }; + + const mockUser = { + id: "user-123", + name: "John", + lastName: "Doe", + email: "john@example.com", + password: "hashedPassword", + wallet: "GABCDEF123456789", + isVerified: false, + verificationToken: null, + verificationTokenExpires: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it("should create a user successfully", async () => { + (mockPrisma.user.create as jest.Mock).mockResolvedValue(mockUser); + + const result = await repository.create(createUserData); + + expect(mockPrisma.user.create).toHaveBeenCalledWith({ + data: { + name: createUserData.name, + lastName: createUserData.lastName, + email: createUserData.email, + password: createUserData.password, + wallet: createUserData.wallet, + isVerified: false, + verificationToken: undefined, + verificationTokenExpires: undefined, + } + }); + + expect(result).toEqual({ + id: mockUser.id, + name: mockUser.name, + lastName: mockUser.lastName, + email: mockUser.email, + wallet: mockUser.wallet, + isVerified: mockUser.isVerified, + verificationToken: mockUser.verificationToken, + verificationTokenExpires: mockUser.verificationTokenExpires, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }); + }); + + it("should throw ResourceConflictError on P2002 error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", { + code: "P2002", + clientVersion: "1.0.0", + }); + + (mockPrisma.user.create as jest.Mock).mockRejectedValue(prismaError); + + await expect(repository.create(createUserData)).rejects.toThrow(ResourceConflictError); + }); + }); + + describe("findById", () => { + const mockUser = { + id: "user-123", + name: "John", + lastName: "Doe", + email: "john@example.com", + password: "hashedPassword", + wallet: "GABCDEF123456789", + isVerified: false, + verificationToken: null, + verificationTokenExpires: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it("should find user by id successfully", async () => { + (mockPrisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + + const result = await repository.findById("user-123"); + + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user-123" } + }); + + expect(result).toEqual({ + id: mockUser.id, + name: mockUser.name, + lastName: mockUser.lastName, + email: mockUser.email, + wallet: mockUser.wallet, + isVerified: mockUser.isVerified, + verificationToken: mockUser.verificationToken, + verificationTokenExpires: mockUser.verificationTokenExpires, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }); + }); + + it("should return null when user not found", async () => { + (mockPrisma.user.findUnique as jest.Mock).mockResolvedValue(null); + + const result = await repository.findById("user-123"); + + expect(result).toBeNull(); + }); + }); + + describe("findByEmail", () => { + const mockUser = { + id: "user-123", + name: "John", + lastName: "Doe", + email: "john@example.com", + password: "hashedPassword", + wallet: "GABCDEF123456789", + isVerified: false, + verificationToken: null, + verificationTokenExpires: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it("should find user by email successfully", async () => { + (mockPrisma.user.findUnique as jest.Mock).mockResolvedValue(mockUser); + + const result = await repository.findByEmail("john@example.com"); + + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: "john@example.com" } + }); + + expect(result).toEqual({ + id: mockUser.id, + name: mockUser.name, + lastName: mockUser.lastName, + email: mockUser.email, + wallet: mockUser.wallet, + isVerified: mockUser.isVerified, + verificationToken: mockUser.verificationToken, + verificationTokenExpires: mockUser.verificationTokenExpires, + createdAt: mockUser.createdAt, + updatedAt: mockUser.updatedAt, + }); + }); + + it("should return null when user not found", async () => { + (mockPrisma.user.findUnique as jest.Mock).mockResolvedValue(null); + + const result = await repository.findByEmail("nonexistent@example.com"); + + expect(result).toBeNull(); + }); + }); + + describe("update", () => { + const updateUserData: UpdateUserData = { + name: "Jane", + lastName: "Smith", + }; + + const mockUpdatedUser = { + id: "user-123", + name: "Jane", + lastName: "Smith", + email: "john@example.com", + password: "hashedPassword", + wallet: "GABCDEF123456789", + isVerified: false, + verificationToken: null, + verificationTokenExpires: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + it("should update user successfully", async () => { + (mockPrisma.user.update as jest.Mock).mockResolvedValue(mockUpdatedUser); + + const result = await repository.update("user-123", updateUserData); + + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-123" }, + data: { + name: "Jane", + lastName: "Smith", + } + }); + + expect(result).toEqual({ + id: mockUpdatedUser.id, + name: mockUpdatedUser.name, + lastName: mockUpdatedUser.lastName, + email: mockUpdatedUser.email, + wallet: mockUpdatedUser.wallet, + isVerified: mockUpdatedUser.isVerified, + verificationToken: mockUpdatedUser.verificationToken, + verificationTokenExpires: mockUpdatedUser.verificationTokenExpires, + createdAt: mockUpdatedUser.createdAt, + updatedAt: mockUpdatedUser.updatedAt, + }); + }); + + it("should throw ResourceNotFoundError on P2025 error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { + code: "P2025", + clientVersion: "1.0.0", + }); + + (mockPrisma.user.update as jest.Mock).mockRejectedValue(prismaError); + + await expect(repository.update("user-123", updateUserData)).rejects.toThrow(ResourceNotFoundError); + }); + }); + + describe("delete", () => { + it("should delete user successfully", async () => { + (mockPrisma.user.delete as jest.Mock).mockResolvedValue({}); + + await repository.delete("user-123"); + + expect(mockPrisma.user.delete).toHaveBeenCalledWith({ + where: { id: "user-123" } + }); + }); + + it("should throw ResourceNotFoundError on P2025 error", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", { + code: "P2025", + clientVersion: "1.0.0", + }); + + (mockPrisma.user.delete as jest.Mock).mockRejectedValue(prismaError); + + await expect(repository.delete("user-123")).rejects.toThrow(ResourceNotFoundError); + }); + }); + + describe("findAll", () => { + const mockUsers = [ + { + id: "user-1", + name: "John", + lastName: "Doe", + email: "john@example.com", + password: "hashedPassword", + wallet: "GABCDEF123456789", + isVerified: false, + verificationToken: null, + verificationTokenExpires: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: "user-2", + name: "Jane", + lastName: "Smith", + email: "jane@example.com", + password: "hashedPassword", + wallet: "GABCDEF987654321", + isVerified: false, + verificationToken: null, + verificationTokenExpires: null, + createdAt: new Date(), + updatedAt: new Date(), + } + ]; + + it("should find all users with pagination", async () => { + (mockPrisma.user.findMany as jest.Mock).mockResolvedValue(mockUsers); + (mockPrisma.user.count as jest.Mock).mockResolvedValue(2); + + const result = await repository.findAll(1, 10); + + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + skip: 0, + take: 10, + orderBy: { createdAt: "desc" }, + }); + + expect(mockPrisma.user.count).toHaveBeenCalled(); + + expect(result).toEqual({ + users: mockUsers.map(user => ({ + id: user.id, + name: user.name, + lastName: user.lastName, + email: user.email, + wallet: user.wallet, + isVerified: user.isVerified, + verificationToken: user.verificationToken, + verificationTokenExpires: user.verificationTokenExpires, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + })), + total: 2 + }); + }); + }); + + describe("error handling", () => { + it("should throw ValidationError on PrismaClientValidationError", async () => { + const validationError = new Prisma.PrismaClientValidationError("Validation failed"); + + (mockPrisma.user.create as jest.Mock).mockRejectedValue(validationError); + + await expect(repository.create({ + name: "John", + email: "invalid-email", + password: "password", + wallet: "invalid-wallet", + })).rejects.toThrow(ValidationError); + }); + + it("should re-throw unexpected errors", async () => { + const unexpectedError = new Error("Unexpected error"); + + (mockPrisma.user.create as jest.Mock).mockRejectedValue(unexpectedError); + + await expect(repository.create({ + name: "John", + email: "john@example.com", + password: "password", + wallet: "GABCDEF123456789", + })).rejects.toThrow("Unexpected error"); + }); + }); +}); diff --git a/src/modules/user/__tests__/user.test.ts b/src/modules/user/__tests__/user.test.ts deleted file mode 100644 index 7363e05..0000000 --- a/src/modules/user/__tests__/user.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { PrismaClient } from "@prisma/client"; - -const prisma = new PrismaClient(); - -describe("Prisma User Operations", () => { - let userId: number; - - beforeAll(async () => { - await prisma.users.deleteMany(); - }); - - afterAll(async () => { - await prisma.$disconnect(); - }); - - test("Should create a new user", async () => { - const newUser = await prisma.users.create({ - data: { - username: "john_doe", - email: "john@example.com", - password_hash: "hashed_password", - role: "user", - }, - }); - - userId = newUser.id; // Store user ID for further tests - expect(newUser).toHaveProperty("id"); - expect(newUser.email).toBe("john@example.com"); - }); - - test("Should retrieve all users", async () => { - const allUsers = await prisma.users.findMany(); - expect(Array.isArray(allUsers)).toBe(true); - expect(allUsers.length).toBeGreaterThan(0); - }); - - test("Should find user by email", async () => { - const user = await prisma.users.findUnique({ - where: { email: "john@example.com" }, - }); - - expect(user).not.toBeNull(); - expect(user?.email).toBe("john@example.com"); - }); - - test("Should update user email", async () => { - const updatedUser = await prisma.users.update({ - where: { id: userId }, - data: { email: "john_updated@example.com" }, - }); - - expect(updatedUser.email).toBe("john_updated@example.com"); - }); - - test("Should delete a user", async () => { - const deletedUser = await prisma.users.delete({ - where: { id: userId }, - }); - - expect(deletedUser.id).toBe(userId); - }); -}); diff --git a/src/modules/user/domain/dto/UserDTO.ts b/src/modules/user/domain/dto/UserDTO.ts new file mode 100644 index 0000000..259e44b --- /dev/null +++ b/src/modules/user/domain/dto/UserDTO.ts @@ -0,0 +1,37 @@ +// UserDTO represents the data structure returned by the repository +export interface UserDTO { + id: string; + name: string; + lastName?: string; + email: string; + wallet: string; + isVerified: boolean; + verificationToken?: string; + verificationTokenExpires?: Date; + createdAt: Date; + updatedAt: Date; +} + +// CreateUserData represents the data needed to create a user +export interface CreateUserData { + name: string; + lastName?: string; + email: string; + password: string; + wallet: string; + isVerified?: boolean; + verificationToken?: string; + verificationTokenExpires?: Date; +} + +// UpdateUserData represents the data that can be updated for a user +export interface UpdateUserData { + name?: string; + lastName?: string; + email?: string; + password?: string; + wallet?: string; + isVerified?: boolean; + verificationToken?: string; + verificationTokenExpires?: Date; +} diff --git a/src/modules/user/domain/interfaces/IUserRepository.ts b/src/modules/user/domain/interfaces/IUserRepository.ts index 859474d..02555bc 100644 --- a/src/modules/user/domain/interfaces/IUserRepository.ts +++ b/src/modules/user/domain/interfaces/IUserRepository.ts @@ -1,21 +1,21 @@ -import { IUser } from "./IUser"; +import { UserDTO, CreateUserData, UpdateUserData } from "../dto/UserDTO"; export interface IUserRepository { - create(user: IUser): Promise; - findById(id: string): Promise; - findByEmail(email: string): Promise; - findByVerificationToken(token: string): Promise; + findById(id: string): Promise; + findByEmail(email: string): Promise; + create(data: CreateUserData): Promise; + update(id: string, data: UpdateUserData): Promise; + delete(id: string): Promise; + findByVerificationToken(token: string): Promise; findAll( page: number, pageSize: number - ): Promise<{ users: IUser[]; total: number }>; - update(user: Partial): Promise; - delete(id: string): Promise; + ): Promise<{ users: UserDTO[]; total: number }>; setVerificationToken( userId: string, token: string, expires: Date - ): Promise; + ): Promise; isUserVerified(userId: string): Promise; - verifyUser(userId: string): Promise; + verifyUser(userId: string): Promise; } diff --git a/src/modules/user/infrastructure/repositories/implementations/user-prisma.repository.ts b/src/modules/user/infrastructure/repositories/implementations/user-prisma.repository.ts new file mode 100644 index 0000000..bb82d24 --- /dev/null +++ b/src/modules/user/infrastructure/repositories/implementations/user-prisma.repository.ts @@ -0,0 +1,215 @@ +import { PrismaClient, Prisma } from "@prisma/client"; +import { IUserRepository } from "../../../domain/interfaces/IUserRepository"; +import { UserDTO, CreateUserData, UpdateUserData } from "../../../domain/dto/UserDTO"; +import { + ResourceNotFoundError, + ResourceConflictError, + ValidationError +} from "../../../../shared/application/errors"; + +/** + * Prisma-backed implementation of IUserRepository + * + * Model Choice: Uses prisma.user (singular) based on the current Prisma schema + * which defines a User model, not a users table. This aligns with the schema + * at prisma/schema.prisma where the model is defined as "model User". + * + * Note: There was a legacy "users" table in the schema, but the current + * implementation uses the proper "User" model for consistency. + */ +export class UserPrismaRepository implements IUserRepository { + constructor(private prisma: PrismaClient) {} + + async findById(id: string): Promise { + try { + const user = await this.prisma.user.findUnique({ + where: { id } + }); + + return user ? this.toDTO(user) : null; + } catch (error) { + this.handlePrismaError(error); + } + } + + async findByEmail(email: string): Promise { + try { + const user = await this.prisma.user.findUnique({ + where: { email } + }); + + return user ? this.toDTO(user) : null; + } catch (error) { + this.handlePrismaError(error); + } + } + + async create(data: CreateUserData): Promise { + try { + const user = await this.prisma.user.create({ + data: { + name: data.name, + lastName: data.lastName, + email: data.email, + password: data.password, + wallet: data.wallet, + isVerified: data.isVerified || false, + verificationToken: data.verificationToken, + verificationTokenExpires: data.verificationTokenExpires, + } + }); + + return this.toDTO(user); + } catch (error) { + this.handlePrismaError(error); + } + } + + async update(id: string, data: UpdateUserData): Promise { + try { + const user = await this.prisma.user.update({ + where: { id }, + data: { + ...(data.name && { name: data.name }), + ...(data.lastName !== undefined && { lastName: data.lastName }), + ...(data.email && { email: data.email }), + ...(data.password && { password: data.password }), + ...(data.wallet && { wallet: data.wallet }), + ...(data.isVerified !== undefined && { isVerified: data.isVerified }), + ...(data.verificationToken !== undefined && { verificationToken: data.verificationToken }), + ...(data.verificationTokenExpires !== undefined && { verificationTokenExpires: data.verificationTokenExpires }), + } + }); + + return this.toDTO(user); + } catch (error) { + this.handlePrismaError(error); + } + } + + async delete(id: string): Promise { + try { + await this.prisma.user.delete({ + where: { id } + }); + } catch (error) { + this.handlePrismaError(error); + } + } + + async findByVerificationToken(token: string): Promise { + try { + const user = await this.prisma.user.findFirst({ + where: { verificationToken: token } + }); + + return user ? this.toDTO(user) : null; + } catch (error) { + this.handlePrismaError(error); + } + } + + async findAll( + page: number, + pageSize: number + ): Promise<{ users: UserDTO[]; total: number }> { + try { + const skip = (page - 1) * pageSize; + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + skip, + take: pageSize, + orderBy: { createdAt: "desc" }, + }), + this.prisma.user.count(), + ]); + + return { + users: users.map(user => this.toDTO(user)), + total + }; + } catch (error) { + this.handlePrismaError(error); + } + } + + async setVerificationToken( + userId: string, + token: string, + expires: Date + ): Promise { + try { + await this.prisma.user.update({ + where: { id: userId }, + data: { + verificationToken: token, + verificationTokenExpires: expires, + }, + }); + } catch (error) { + this.handlePrismaError(error); + } + } + + async isUserVerified(userId: string): Promise { + try { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { isVerified: true }, + }); + return user?.isVerified || false; + } catch (error) { + this.handlePrismaError(error); + } + } + + async verifyUser(userId: string): Promise { + try { + await this.prisma.user.update({ + where: { id: userId }, + data: { + isVerified: true, + verificationToken: null, + verificationTokenExpires: null, + }, + }); + } catch (error) { + this.handlePrismaError(error); + } + } + + private toDTO(user: any): UserDTO { + return { + id: user.id, + name: user.name, + lastName: user.lastName, + email: user.email, + wallet: user.wallet, + isVerified: user.isVerified, + verificationToken: user.verificationToken, + verificationTokenExpires: user.verificationTokenExpires, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + } + + private handlePrismaError(error: any): never { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error.code) { + case 'P2002': + throw new ResourceConflictError('A user with this email or wallet already exists'); + case 'P2025': + throw new ResourceNotFoundError('User not found'); + default: + throw new ValidationError(`Database operation failed: ${error.message}`); + } + } + + if (error instanceof Prisma.PrismaClientValidationError) { + throw new ValidationError(`Validation error: ${error.message}`); + } + + // Re-throw unexpected errors + throw error; + } +} diff --git a/src/modules/user/presentation/controllers/UserController.ts b/src/modules/user/presentation/controllers/UserController.ts new file mode 100644 index 0000000..dc3f504 --- /dev/null +++ b/src/modules/user/presentation/controllers/UserController.ts @@ -0,0 +1,118 @@ +import { Request, Response } from "express"; +import { CreateUserUseCase, GetUserByIdUseCase, GetUserByEmailUseCase, UpdateUserUseCase, DeleteUserUseCase } from "../../use-cases/userUseCase"; +import { CreateUserDto } from "../../dto/CreateUserDto"; +import { UpdateUserDto } from "../../dto/UpdateUserDto"; +import { container, USER_REPOSITORY } from "../../../../shared/infrastructure/container"; +import { IUserRepository } from "../../domain/interfaces/IUserRepository"; + +export default class UserController { + private userRepository: IUserRepository; + + constructor() { + this.userRepository = container[USER_REPOSITORY] as IUserRepository; + } + + async createUser(req: Request, res: Response) { + try { + const createUserDto = new CreateUserDto(); + Object.assign(createUserDto, req.body); + + const createUserUseCase = new CreateUserUseCase(this.userRepository); + const user = await createUserUseCase.execute(createUserDto); + + res.status(201).json(user); + } catch (error: any) { + if (error.message?.includes("already exists")) { + res.status(409).json({ error: error.message }); + } else if (error.message?.includes("validation")) { + res.status(400).json({ error: error.message }); + } else { + res.status(500).json({ error: "Internal server error" }); + } + } + } + + async getUserById(req: Request, res: Response) { + try { + const { id } = req.params; + + const getUserByIdUseCase = new GetUserByIdUseCase(this.userRepository); + const user = await getUserByIdUseCase.execute(id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + res.status(200).json(user); + } catch (error: any) { + if (error.message?.includes("not found")) { + res.status(404).json({ error: error.message }); + } else { + res.status(500).json({ error: "Internal server error" }); + } + } + } + + async getUserByEmail(req: Request, res: Response) { + try { + const { email } = req.query; + + if (!email || typeof email !== 'string') { + return res.status(400).json({ error: "Email is required" }); + } + + const getUserByEmailUseCase = new GetUserByEmailUseCase(this.userRepository); + const user = await getUserByEmailUseCase.execute(email); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + res.status(200).json(user); + } catch (error: any) { + if (error.message?.includes("not found")) { + res.status(404).json({ error: error.message }); + } else { + res.status(500).json({ error: "Internal server error" }); + } + } + } + + async updateUser(req: Request, res: Response) { + try { + const { id } = req.params; + const updateUserDto = new UpdateUserDto(); + Object.assign(updateUserDto, req.body); + + const updateUserUseCase = new UpdateUserUseCase(this.userRepository); + const user = await updateUserUseCase.execute(id, updateUserDto); + + res.status(200).json({ message: "User updated successfully", user }); + } catch (error: any) { + if (error.message?.includes("not found")) { + res.status(404).json({ error: error.message }); + } else if (error.message?.includes("already exists")) { + res.status(409).json({ error: error.message }); + } else { + res.status(500).json({ error: "Internal server error" }); + } + } + } + + async deleteUser(req: Request, res: Response) { + try { + const { id } = req.params; + + const deleteUserUseCase = new DeleteUserUseCase(this.userRepository); + await deleteUserUseCase.execute(id); + + res.status(200).json({ message: "User deleted successfully" }); + } catch (error: any) { + if (error.message?.includes("not found")) { + res.status(404).json({ error: error.message }); + } else { + res.status(500).json({ error: "Internal server error" }); + } + } + } +} diff --git a/src/modules/user/readme.md b/src/modules/user/readme.md index b03a0a0..48e9080 100644 --- a/src/modules/user/readme.md +++ b/src/modules/user/readme.md @@ -1,14 +1,51 @@ +# User Module + +This module follows the Domain-Driven Design (DDD) architecture pattern with clean separation of concerns. + +## Architecture + +``` src/modules/user/ ├── domain/ -│ ├── entities/ -│ │ ├── User.ts -│ ├── interfaces/ -│ │ ├── IUser.ts -│ │ ├── IUserRepository.ts -├── repositories/ -│ ├── PrismaUserRepository.ts -├── use-cases/ -│ ├── CreateUserUseCase.ts -├── dto/ -│ ├── CreateUserDto.ts -│ ├── UpdateUserDto.ts +│ ├── entities/ # Domain entities (User.ts) +│ ├── interfaces/ # Repository interfaces (IUserRepository.ts) +│ └── dto/ # Data Transfer Objects (UserDTO.ts) +├── infrastructure/ +│ └── repositories/ +│ └── implementations/ +│ └── user-prisma.repository.ts # Prisma implementation +├── presentation/ +│ └── controllers/ # HTTP controllers (UserController.ts) +├── use-cases/ # Business logic (userUseCase.ts) +└── dto/ # Request/Response DTOs + ├── CreateUserDto.ts + └── UpdateUserDto.ts +``` + +## Repository Pattern + +- **IUserRepository**: Interface defining all user data operations +- **UserPrismaRepository**: Prisma-backed implementation (only file that touches Prisma) +- **Error Mapping**: Prisma errors are mapped to domain exceptions: + - P2002 → ResourceConflictError (409) + - P2025 → ResourceNotFoundError (404) + - Validation errors → ValidationError (400) + +## Dependencies + +- Controllers and use-cases depend on `IUserRepository` interface +- Repository is injected via DI container (`USER_REPOSITORY` token) +- No direct Prisma imports outside the infrastructure layer + +## Database Model + +Uses the `User` model from Prisma schema (not the legacy `users` table): +- `id`, `name`, `lastName`, `email`, `password`, `wallet` +- `isVerified`, `verificationToken`, `verificationTokenExpires` +- `createdAt`, `updatedAt` timestamps + +## Testing + +- **Unit Tests**: Repository implementation with mocked Prisma +- **Integration Tests**: Controller endpoints with mocked repository +- **Error Handling**: Tests cover all error mapping scenarios diff --git a/src/modules/user/repositories/PrismaUserRepository.ts b/src/modules/user/repositories/PrismaUserRepository.ts deleted file mode 100644 index 714e4f6..0000000 --- a/src/modules/user/repositories/PrismaUserRepository.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { PrismaClient } from "@prisma/client"; -import { IUserRepository } from "../domain/interfaces/IUserRepository"; -import { IUser } from "../domain/interfaces/IUser"; - -const prisma = new PrismaClient(); - -export class PrismaUserRepository implements IUserRepository { - async create(user: IUser): Promise { - return prisma.user.create({ data: user }); - } - - async findById(id: string): Promise { - return prisma.user.findUnique({ where: { id } }); - } - - async findByEmail(email: string): Promise { - return prisma.user.findUnique({ where: { email } }); - } - - async update(user: IUser): Promise { - return prisma.user.update({ where: { id: user.id }, data: user }); - } - - async findAll( - page: number, - pageSize: number - ): Promise<{ users: any[]; total: number }> { - const skip = (page - 1) * pageSize; - const [users, total] = await Promise.all([ - prisma.user.findMany({ - skip, - take: pageSize, - orderBy: { createdAt: "desc" }, - }), - prisma.user.count(), - ]); - return { users, total }; - } - - async delete(id: string): Promise { - await prisma.user.delete({ where: { id } }); - } - - async findByVerificationToken(token: string): Promise { - return prisma.user.findFirst({ where: { verificationToken: token } }); - } - - async setVerificationToken( - userId: string, - token: string, - expires: Date - ): Promise { - await prisma.user.update({ - where: { id: userId }, - data: { - verificationToken: token, - verificationTokenExpires: expires, - }, - }); - } - - async verifyUser(userId: string): Promise { - await prisma.user.update({ - where: { id: userId }, - data: { - isVerified: true, - verificationToken: null, - verificationTokenExpires: null, - }, - }); - } - - async isUserVerified(userId: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { isVerified: true }, - }); - return user?.isVerified || false; - } -} diff --git a/src/modules/user/use-cases/userUseCase.ts b/src/modules/user/use-cases/userUseCase.ts index fe4e8a0..ab7fe27 100644 --- a/src/modules/user/use-cases/userUseCase.ts +++ b/src/modules/user/use-cases/userUseCase.ts @@ -1,30 +1,32 @@ import { IUserRepository } from "../domain/interfaces/IUserRepository"; import { CreateUserDto } from "../dto/CreateUserDto"; -import { User } from "../domain/entities/User.entity"; -import bcrypt from "bcryptjs"; import { UpdateUserDto } from "../dto/UpdateUserDto"; +import { UserDTO, CreateUserData, UpdateUserData } from "../domain/dto/UserDTO"; +import bcrypt from "bcryptjs"; export class CreateUserUseCase { constructor(private userRepository: IUserRepository) {} - async execute(data: CreateUserDto) { + async execute(data: CreateUserDto): Promise { const hashedPassword = bcrypt.hashSync(data.password, 10); - const user = new User(); - user.id = crypto.randomUUID(); - user.name = data.name; - user.lastName = data.lastName; - user.email = data.email; - user.password = hashedPassword; - user.wallet = data.wallet; - return this.userRepository.create(user); + const createUserData: CreateUserData = { + name: data.name, + lastName: data.lastName, + email: data.email, + password: hashedPassword, + wallet: data.wallet || "", + isVerified: false, + }; + + return this.userRepository.create(createUserData); } } export class GetUserByIdUseCase { constructor(private userRepository: IUserRepository) {} - async execute(id: string) { + async execute(id: string): Promise { if (!id) { throw new Error("User ID is required."); } @@ -41,7 +43,7 @@ export class GetUserByIdUseCase { export class GetUserByEmailUseCase { constructor(private userRepository: IUserRepository) {} - async execute(email: string) { + async execute(email: string): Promise { if (!email) { throw new Error("Email is required."); } @@ -78,7 +80,15 @@ export class DeleteUserUseCase { export class UpdateUserUseCase { constructor(private userRepository: IUserRepository) {} - async execute(data: UpdateUserDto): Promise { - await this.userRepository.update(data); + async execute(id: string, data: UpdateUserDto): Promise { + const updateUserData: UpdateUserData = { + ...(data.name && { name: data.name }), + ...(data.lastName !== undefined && { lastName: data.lastName }), + ...(data.email && { email: data.email }), + ...(data.password && { password: data.password }), + ...(data.wallet && { wallet: data.wallet }), + }; + + return this.userRepository.update(id, updateUserData); } } diff --git a/src/modules/wallet/__tests__/WalletAuthIntegration.test.ts b/src/modules/wallet/__tests__/WalletAuthIntegration.test.ts index 3f04108..4bec948 100644 --- a/src/modules/wallet/__tests__/WalletAuthIntegration.test.ts +++ b/src/modules/wallet/__tests__/WalletAuthIntegration.test.ts @@ -1,5 +1,6 @@ import AuthService from "../../../services/AuthService"; import { WalletService } from "../application/services/WalletService"; +import { container } from "../../../shared/infrastructure/container"; // Mock the wallet service jest.mock("../application/services/WalletService"); @@ -13,10 +14,18 @@ jest.mock("../../../config/prisma", () => ({ })); // Mock other dependencies -jest.mock("../../../modules/user/repositories/PrismaUserRepository"); -jest.mock("../../../modules/auth/use-cases/send-verification-email.usecase"); -jest.mock("../../../modules/auth/use-cases/verify-email.usecase"); -jest.mock("../../../modules/auth/use-cases/resend-verification-email.usecase"); +jest.mock("../../../shared/infrastructure/container", () => ({ + container: { + [Symbol.for("USER_REPOSITORY")]: { + findById: jest.fn(), + findByEmail: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + USER_REPOSITORY: Symbol.for("USER_REPOSITORY"), +})); describe("Wallet Auth Integration", () => { let authService: AuthService; @@ -94,8 +103,7 @@ describe("Wallet Auth Integration", () => { it("should register user with valid wallet", async () => { const { prisma } = require("../../../config/prisma"); - const mockUserRepository = - require("../../../modules/user/repositories/PrismaUserRepository").PrismaUserRepository; + const mockUserRepository = container[Symbol.for("USER_REPOSITORY")] as any; const mockSendEmailUseCase = require("../../../modules/auth/use-cases/send-verification-email.usecase").SendVerificationEmailUseCase; @@ -172,8 +180,7 @@ describe("Wallet Auth Integration", () => { it("should reject registration with already registered wallet", async () => { const { prisma } = require("../../../config/prisma"); - const mockUserRepository = - require("../../../modules/user/repositories/PrismaUserRepository").PrismaUserRepository; + const mockUserRepository = container[Symbol.for("USER_REPOSITORY")] as any; mockWalletService.verifyWallet.mockResolvedValue({ success: true, diff --git a/src/shared/infrastructure/container.ts b/src/shared/infrastructure/container.ts index f31aa07..9da04a7 100644 --- a/src/shared/infrastructure/container.ts +++ b/src/shared/infrastructure/container.ts @@ -1,6 +1,13 @@ import { CertificateService } from "../../modules/certificate/application/services/CertificateService"; import { ICertificateService } from "../../modules/certificate/domain/interfaces/ICertificateService"; +import { IUserRepository } from "../../modules/user/domain/interfaces/IUserRepository"; +import { UserPrismaRepository } from "../../modules/user/infrastructure/repositories/implementations/user-prisma.repository"; +import { prisma } from "../../config/prisma"; + +// Repository tokens +export const USER_REPOSITORY = Symbol.for("USER_REPOSITORY"); export const container = { certificateService: new CertificateService() as ICertificateService, + [USER_REPOSITORY]: new UserPrismaRepository(prisma) as IUserRepository, };