From 75944635dabfd0a772fb6df9c8c1257a04b93638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Acu=C3=B1a=20L=C3=B3pez?= <89756491+davidacunalopez@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:55:38 -0600 Subject: [PATCH] feat: implement NFT domain exceptions and unified error handling - Add NFT-specific domain exceptions (NFTNotFoundError, NFTAlreadyMintedError, etc.) - Update NFT entity to use domain exceptions instead of generic Error - Enhance NFT repository with proper error handling and mapping - Refactor all NFT use cases to use domain exceptions - Create comprehensive NFT service with business logic - Implement functional NFT controller with proper HTTP error mapping - Add complete test coverage for exceptions, service, and controller - Create module index file for clean exports - Follow DDD principles and best practices for error handling --- .../application/services/nft.service.test.ts | 440 ++++++++++++++ .../domain/exceptions/nft.exception.test.ts | 186 ++++++ .../controllers/nft.controller.test.ts | 537 ++++++++++++++++++ .../nft/application/services/nft.service.ts | 295 ++++++++++ src/modules/nft/domain/entities/nft.entity.ts | 16 +- src/modules/nft/domain/exceptions/index.ts | 1 + .../nft/domain/exceptions/nft.exception.ts | 43 ++ src/modules/nft/index.ts | 22 + .../controllers/nft.controller.ts | 244 ++++++++ .../nft/repositories/nft.repository.ts | 184 ++++-- src/modules/nft/use-cases/createNFT.ts | 16 + src/modules/nft/use-cases/deleteNFT.ts | 11 + src/modules/nft/use-cases/getNFT.ts | 13 +- src/modules/nft/use-cases/getNFTByUserId.ts | 13 + 14 files changed, 1958 insertions(+), 63 deletions(-) create mode 100644 src/modules/nft/__tests__/application/services/nft.service.test.ts create mode 100644 src/modules/nft/__tests__/domain/exceptions/nft.exception.test.ts create mode 100644 src/modules/nft/__tests__/presentation/controllers/nft.controller.test.ts create mode 100644 src/modules/nft/application/services/nft.service.ts create mode 100644 src/modules/nft/domain/exceptions/index.ts create mode 100644 src/modules/nft/domain/exceptions/nft.exception.ts create mode 100644 src/modules/nft/index.ts create mode 100644 src/modules/nft/presentation/controllers/nft.controller.ts diff --git a/src/modules/nft/__tests__/application/services/nft.service.test.ts b/src/modules/nft/__tests__/application/services/nft.service.test.ts new file mode 100644 index 0000000..da1262d --- /dev/null +++ b/src/modules/nft/__tests__/application/services/nft.service.test.ts @@ -0,0 +1,440 @@ +import { NFTService } from "../../../application/services/nft.service"; +import { INFTRepository } from "../../../domain/interfaces/nft.interface"; +import { NFTDomain as NFT } from "../../../domain/entities/nft.entity"; +import { + NFTNotFoundError, + NFTValidationError, + NFTDatabaseError, + NFTAlreadyMintedError, +} from "../../../domain/exceptions"; + +// Mock repository +const mockNFTRepository: jest.Mocked = { + create: jest.fn(), + findById: jest.fn(), + findByUserId: jest.fn(), + update: jest.fn(), + delete: jest.fn(), +}; + +describe("NFTService", () => { + let nftService: NFTService; + + beforeEach(() => { + jest.clearAllMocks(); + nftService = new NFTService(mockNFTRepository); + }); + + describe("createNFT", () => { + const validData = { + userId: "user-123", + organizationId: "org-123", + description: "Test NFT Description", + }; + + it("should create NFT successfully with valid data", async () => { + const expectedNFT = new NFT( + "nft-123", + validData.userId, + validData.organizationId, + validData.description, + new Date() + ); + + mockNFTRepository.create.mockResolvedValue(expectedNFT); + + const result = await nftService.createNFT(validData); + + expect(result).toEqual(expectedNFT); + expect(mockNFTRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: validData.userId, + organizationId: validData.organizationId, + description: validData.description, + }) + ); + }); + + it("should throw NFTValidationError when userId is missing", async () => { + const invalidData = { ...validData, userId: "" }; + + await expect(nftService.createNFT(invalidData)).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.create).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when organizationId is missing", async () => { + const invalidData = { ...validData, organizationId: "" }; + + await expect(nftService.createNFT(invalidData)).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.create).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when description is missing", async () => { + const invalidData = { ...validData, description: "" }; + + await expect(nftService.createNFT(invalidData)).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.create).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when description is empty", async () => { + const invalidData = { ...validData, description: " " }; + + await expect(nftService.createNFT(invalidData)).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.create).not.toHaveBeenCalled(); + }); + + it("should throw NFTDatabaseError when repository create fails", async () => { + const dbError = new Error("Database connection failed"); + mockNFTRepository.create.mockRejectedValue(dbError); + + await expect(nftService.createNFT(validData)).rejects.toThrow( + NFTDatabaseError + ); + }); + }); + + describe("getNFTById", () => { + const nftId = "nft-123"; + const expectedNFT = new NFT( + nftId, + "user-123", + "org-123", + "Test NFT", + new Date() + ); + + it("should return NFT when found", async () => { + mockNFTRepository.findById.mockResolvedValue(expectedNFT); + + const result = await nftService.getNFTById(nftId); + + expect(result).toEqual(expectedNFT); + expect(mockNFTRepository.findById).toHaveBeenCalledWith(nftId); + }); + + it("should throw NFTValidationError when id is missing", async () => { + await expect(nftService.getNFTById("")).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when id is empty", async () => { + await expect(nftService.getNFTById(" ")).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NFTNotFoundError when NFT is not found", async () => { + mockNFTRepository.findById.mockResolvedValue(null); + + await expect(nftService.getNFTById(nftId)).rejects.toThrow( + NFTNotFoundError + ); + }); + + it("should throw NFTDatabaseError when repository findById fails", async () => { + const dbError = new Error("Database connection failed"); + mockNFTRepository.findById.mockRejectedValue(dbError); + + await expect(nftService.getNFTById(nftId)).rejects.toThrow( + NFTDatabaseError + ); + }); + }); + + describe("getNFTsByUserId", () => { + const userId = "user-123"; + const expectedResult = { + nfts: [ + new NFT("nft-1", userId, "org-1", "NFT 1", new Date()), + new NFT("nft-2", userId, "org-2", "NFT 2", new Date()), + ], + total: 2, + }; + + it("should return NFTs when found", async () => { + mockNFTRepository.findByUserId.mockResolvedValue(expectedResult); + + const result = await nftService.getNFTsByUserId(userId, 1, 10); + + expect(result).toEqual(expectedResult); + expect(mockNFTRepository.findByUserId).toHaveBeenCalledWith( + userId, + 1, + 10 + ); + }); + + it("should use default pagination values", async () => { + mockNFTRepository.findByUserId.mockResolvedValue(expectedResult); + + await nftService.getNFTsByUserId(userId); + + expect(mockNFTRepository.findByUserId).toHaveBeenCalledWith( + userId, + 1, + 10 + ); + }); + + it("should throw NFTValidationError when userId is missing", async () => { + await expect(nftService.getNFTsByUserId("")).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findByUserId).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when page is less than 1", async () => { + await expect(nftService.getNFTsByUserId(userId, 0, 10)).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findByUserId).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when pageSize is less than 1", async () => { + await expect(nftService.getNFTsByUserId(userId, 1, 0)).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findByUserId).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when pageSize is greater than 100", async () => { + await expect(nftService.getNFTsByUserId(userId, 1, 101)).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findByUserId).not.toHaveBeenCalled(); + }); + + it("should throw NFTDatabaseError when repository findByUserId fails", async () => { + const dbError = new Error("Database connection failed"); + mockNFTRepository.findByUserId.mockRejectedValue(dbError); + + await expect(nftService.getNFTsByUserId(userId, 1, 10)).rejects.toThrow( + NFTDatabaseError + ); + }); + }); + + describe("deleteNFT", () => { + const nftId = "nft-123"; + const existingNFT = new NFT( + nftId, + "user-123", + "org-123", + "Test NFT", + new Date() + ); + + it("should delete NFT successfully when it exists", async () => { + mockNFTRepository.findById.mockResolvedValue(existingNFT); + mockNFTRepository.delete.mockResolvedValue(); + + await nftService.deleteNFT(nftId); + + expect(mockNFTRepository.findById).toHaveBeenCalledWith(nftId); + expect(mockNFTRepository.delete).toHaveBeenCalledWith(nftId); + }); + + it("should throw NFTValidationError when id is missing", async () => { + await expect(nftService.deleteNFT("")).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + expect(mockNFTRepository.delete).not.toHaveBeenCalled(); + }); + + it("should throw NFTNotFoundError when NFT does not exist", async () => { + mockNFTRepository.findById.mockResolvedValue(null); + + await expect(nftService.deleteNFT(nftId)).rejects.toThrow( + NFTNotFoundError + ); + expect(mockNFTRepository.delete).not.toHaveBeenCalled(); + }); + + it("should throw NFTDatabaseError when repository findById fails", async () => { + const dbError = new Error("Database connection failed"); + mockNFTRepository.findById.mockRejectedValue(dbError); + + await expect(nftService.deleteNFT(nftId)).rejects.toThrow( + NFTDatabaseError + ); + }); + + it("should throw NFTDatabaseError when repository delete fails", async () => { + mockNFTRepository.findById.mockResolvedValue(existingNFT); + const dbError = new Error("Database connection failed"); + mockNFTRepository.delete.mockRejectedValue(dbError); + + await expect(nftService.deleteNFT(nftId)).rejects.toThrow( + NFTDatabaseError + ); + }); + }); + + describe("mintNFT", () => { + const nftId = "nft-123"; + const tokenId = "token-123"; + const contractAddress = "0x123456789"; + const metadataUri = "https://metadata.uri"; + const existingNFT = new NFT( + nftId, + "user-123", + "org-123", + "Test NFT", + new Date() + ); + + it("should mint NFT successfully", async () => { + mockNFTRepository.findById.mockResolvedValue(existingNFT); + mockNFTRepository.update.mockResolvedValue({ + ...existingNFT, + tokenId, + contractAddress, + metadataUri, + isMinted: true, + }); + + const result = await nftService.mintNFT( + nftId, + tokenId, + contractAddress, + metadataUri + ); + + expect(result.isMinted).toBe(true); + expect(result.tokenId).toBe(tokenId); + expect(result.contractAddress).toBe(contractAddress); + expect(result.metadataUri).toBe(metadataUri); + expect(mockNFTRepository.update).toHaveBeenCalledWith(nftId, { + tokenId, + contractAddress, + metadataUri, + isMinted: true, + }); + }); + + it("should mint NFT without metadata URI", async () => { + mockNFTRepository.findById.mockResolvedValue(existingNFT); + mockNFTRepository.update.mockResolvedValue({ + ...existingNFT, + tokenId, + contractAddress, + isMinted: true, + }); + + const result = await nftService.mintNFT(nftId, tokenId, contractAddress); + + expect(result.isMinted).toBe(true); + expect(result.tokenId).toBe(tokenId); + expect(result.contractAddress).toBe(contractAddress); + }); + + it("should throw NFTValidationError when id is missing", async () => { + await expect( + nftService.mintNFT("", tokenId, contractAddress) + ).rejects.toThrow(NFTValidationError); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when tokenId is missing", async () => { + await expect( + nftService.mintNFT(nftId, "", contractAddress) + ).rejects.toThrow(NFTValidationError); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when contractAddress is missing", async () => { + await expect(nftService.mintNFT(nftId, tokenId, "")).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NFTNotFoundError when NFT does not exist", async () => { + mockNFTRepository.findById.mockResolvedValue(null); + + await expect( + nftService.mintNFT(nftId, tokenId, contractAddress) + ).rejects.toThrow(NFTNotFoundError); + expect(mockNFTRepository.update).not.toHaveBeenCalled(); + }); + + it("should throw NFTAlreadyMintedError when NFT is already minted", async () => { + const mintedNFT = { ...existingNFT, isMinted: true }; + mockNFTRepository.findById.mockResolvedValue(mintedNFT); + + await expect( + nftService.mintNFT(nftId, tokenId, contractAddress) + ).rejects.toThrow(NFTAlreadyMintedError); + expect(mockNFTRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe("updateNFTMetadata", () => { + const nftId = "nft-123"; + const metadataUri = "https://new-metadata.uri"; + const existingNFT = new NFT( + nftId, + "user-123", + "org-123", + "Test NFT", + new Date() + ); + + it("should update metadata successfully", async () => { + mockNFTRepository.findById.mockResolvedValue(existingNFT); + mockNFTRepository.update.mockResolvedValue({ + ...existingNFT, + metadataUri, + }); + + const result = await nftService.updateNFTMetadata(nftId, metadataUri); + + expect(result.metadataUri).toBe(metadataUri); + expect(mockNFTRepository.update).toHaveBeenCalledWith(nftId, { + metadataUri, + }); + }); + + it("should throw NFTValidationError when id is missing", async () => { + await expect( + nftService.updateNFTMetadata("", metadataUri) + ).rejects.toThrow(NFTValidationError); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when metadataUri is missing", async () => { + await expect(nftService.updateNFTMetadata(nftId, "")).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NFTValidationError when metadataUri is empty", async () => { + await expect(nftService.updateNFTMetadata(nftId, " ")).rejects.toThrow( + NFTValidationError + ); + expect(mockNFTRepository.findById).not.toHaveBeenCalled(); + }); + + it("should throw NFTNotFoundError when NFT does not exist", async () => { + mockNFTRepository.findById.mockResolvedValue(null); + + await expect( + nftService.updateNFTMetadata(nftId, metadataUri) + ).rejects.toThrow(NFTNotFoundError); + expect(mockNFTRepository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/nft/__tests__/domain/exceptions/nft.exception.test.ts b/src/modules/nft/__tests__/domain/exceptions/nft.exception.test.ts new file mode 100644 index 0000000..c2cc770 --- /dev/null +++ b/src/modules/nft/__tests__/domain/exceptions/nft.exception.test.ts @@ -0,0 +1,186 @@ +import { + NFTNotFoundError, + NFTAlreadyMintedError, + NFTValidationError, + NFTDatabaseError, + NFTMintingError, + NFTMetadataError, +} from "../../../domain/exceptions"; + +describe("NFT Domain Exceptions", () => { + describe("NFTNotFoundError", () => { + it("should create NFTNotFoundError with correct message and status", () => { + const nftId = "nft-123"; + const error = new NFTNotFoundError(nftId); + + expect(error.message).toBe(`NFT with ID ${nftId} not found`); + expect(error.statusCode).toBe(404); + expect(error.code).toBe("RESOURCE_NOT_FOUND"); + expect(error.name).toBe("NFTNotFoundError"); + }); + + it("should extend ResourceNotFoundError", () => { + const error = new NFTNotFoundError("test-id"); + expect(error).toBeInstanceOf(Error); + }); + }); + + describe("NFTAlreadyMintedError", () => { + it("should create NFTAlreadyMintedError with correct message and status", () => { + const nftId = "nft-456"; + const error = new NFTAlreadyMintedError(nftId); + + expect(error.message).toBe(`NFT with ID ${nftId} is already minted`); + expect(error.statusCode).toBe(409); + expect(error.code).toBe("RESOURCE_CONFLICT"); + expect(error.name).toBe("NFTAlreadyMintedError"); + }); + + it("should extend ResourceConflictError", () => { + const error = new NFTAlreadyMintedError("test-id"); + expect(error).toBeInstanceOf(Error); + }); + }); + + describe("NFTValidationError", () => { + it("should create NFTValidationError with message only", () => { + const message = "Invalid NFT data"; + const error = new NFTValidationError(message); + + expect(error.message).toBe(message); + expect(error.statusCode).toBe(400); + expect(error.code).toBe("VALIDATION_ERROR"); + expect(error.name).toBe("NFTValidationError"); + expect(error.details).toBeUndefined(); + }); + + it("should create NFTValidationError with message and details", () => { + const message = "Invalid NFT data"; + const details = { field: "description", value: "" }; + const error = new NFTValidationError(message, details); + + expect(error.message).toBe(message); + expect(error.statusCode).toBe(400); + expect(error.code).toBe("VALIDATION_ERROR"); + expect(error.details).toEqual(details); + }); + + it("should extend ValidationError", () => { + const error = new NFTValidationError("test message"); + expect(error).toBeInstanceOf(Error); + }); + }); + + describe("NFTDatabaseError", () => { + it("should create NFTDatabaseError with message only", () => { + const message = "Database connection failed"; + const error = new NFTDatabaseError(message); + + expect(error.message).toBe(`NFT database operation failed: ${message}`); + expect(error.statusCode).toBe(500); + expect(error.code).toBe("DATABASE_ERROR"); + expect(error.name).toBe("NFTDatabaseError"); + expect(error.details).toBeUndefined(); + }); + + it("should create NFTDatabaseError with message and details", () => { + const message = "Database connection failed"; + const details = { operation: "create", table: "nfts" }; + const error = new NFTDatabaseError(message, details); + + expect(error.message).toBe(`NFT database operation failed: ${message}`); + expect(error.statusCode).toBe(500); + expect(error.code).toBe("DATABASE_ERROR"); + expect(error.details).toEqual(details); + }); + + it("should extend DatabaseError", () => { + const error = new NFTDatabaseError("test message"); + expect(error).toBeInstanceOf(Error); + }); + }); + + describe("NFTMintingError", () => { + it("should create NFTMintingError with message only", () => { + const message = "Smart contract error"; + const error = new NFTMintingError(message); + + expect(error.message).toBe(`NFT minting failed: ${message}`); + expect(error.statusCode).toBe(500); + expect(error.code).toBe("INTERNAL_ERROR"); + expect(error.name).toBe("NFTMintingError"); + expect(error.details).toBeUndefined(); + }); + + it("should create NFTMintingError with message and details", () => { + const message = "Smart contract error"; + const details = { contractAddress: "0x123", gasUsed: "50000" }; + const error = new NFTMintingError(message, details); + + expect(error.message).toBe(`NFT minting failed: ${message}`); + expect(error.statusCode).toBe(500); + expect(error.code).toBe("INTERNAL_ERROR"); + expect(error.details).toEqual(details); + }); + + it("should extend InternalServerError", () => { + const error = new NFTMintingError("test message"); + expect(error).toBeInstanceOf(Error); + }); + }); + + describe("NFTMetadataError", () => { + it("should create NFTMetadataError with message only", () => { + const message = "Invalid metadata format"; + const error = new NFTMetadataError(message); + + expect(error.message).toBe(`NFT metadata error: ${message}`); + expect(error.statusCode).toBe(400); + expect(error.code).toBe("VALIDATION_ERROR"); + expect(error.name).toBe("NFTMetadataError"); + expect(error.details).toBeUndefined(); + }); + + it("should create NFTMetadataError with message and details", () => { + const message = "Invalid metadata format"; + const details = { format: "JSON", issue: "malformed" }; + const error = new NFTMetadataError(message, details); + + expect(error.message).toBe(`NFT metadata error: ${message}`); + expect(error.statusCode).toBe(400); + expect(error.code).toBe("VALIDATION_ERROR"); + expect(error.details).toEqual(details); + }); + + it("should extend ValidationError", () => { + const error = new NFTMetadataError("test message"); + expect(error).toBeInstanceOf(Error); + }); + }); + + describe("Error serialization", () => { + it("should serialize NFTNotFoundError correctly", () => { + const error = new NFTNotFoundError("nft-123"); + const serialized = error.toJSON(); + + expect(serialized).toEqual({ + statusCode: 404, + message: "NFT with ID nft-123 not found", + errorCode: "RESOURCE_NOT_FOUND", + }); + }); + + it("should serialize NFTValidationError with details correctly", () => { + const details = { field: "description" }; + const error = new NFTValidationError("Invalid data", details); + const serialized = error.toJSON(); + + expect(serialized).toEqual({ + statusCode: 400, + message: "Invalid data", + errorCode: "VALIDATION_ERROR", + details, + }); + }); + }); +}); diff --git a/src/modules/nft/__tests__/presentation/controllers/nft.controller.test.ts b/src/modules/nft/__tests__/presentation/controllers/nft.controller.test.ts new file mode 100644 index 0000000..cf8ea5a --- /dev/null +++ b/src/modules/nft/__tests__/presentation/controllers/nft.controller.test.ts @@ -0,0 +1,537 @@ +import { Request, Response } from "express"; +import { NFTController } from "../../../presentation/controllers/nft.controller"; +import { CreateNFT } from "../../../use-cases/createNFT"; +import { GetNFT } from "../../../use-cases/getNFT"; +import { GetNFTByUserId } from "../../../use-cases/getNFTByUserId"; +import { DeleteNFT } from "../../../use-cases/deleteNFT"; +import { + NFTNotFoundError, + NFTValidationError, + NFTDatabaseError, + NFTAlreadyMintedError, +} from "../../../domain/exceptions"; +// ValidationError is used in the test cases + +// Mock use cases +const mockCreateNFTUseCase = { + execute: jest.fn(), +} as jest.Mocked; + +const mockGetNFTUseCase = { + execute: jest.fn(), +} as jest.Mocked; + +const mockGetNFTByUserIdUseCase = { + execute: jest.fn(), +} as jest.Mocked; + +const mockDeleteNFTUseCase = { + execute: jest.fn(), +} as jest.Mocked; + +describe("NFTController", () => { + let nftController: NFTController; + let mockRequest: Partial; + let mockResponse: Partial; + + beforeEach(() => { + jest.clearAllMocks(); + + nftController = new NFTController( + mockCreateNFTUseCase, + mockGetNFTUseCase, + mockGetNFTByUserIdUseCase, + mockDeleteNFTUseCase + ); + + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + }); + + describe("createNFT", () => { + const validData = { + userId: "user-123", + organizationId: "org-123", + description: "Test NFT Description", + }; + + beforeEach(() => { + mockRequest = { + body: validData, + }; + }); + + it("should create NFT successfully with valid data", async () => { + const expectedNFT = { + id: "nft-123", + userId: validData.userId, + organizationId: validData.organizationId, + description: validData.description, + createdAt: new Date(), + }; + + mockCreateNFTUseCase.execute.mockResolvedValue(expectedNFT); + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockCreateNFTUseCase.execute).toHaveBeenCalledWith(validData); + expect(mockResponse.status).toHaveBeenCalledWith(201); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: expectedNFT, + message: "NFT created successfully", + }); + }); + + it("should return 400 when userId is missing", async () => { + mockRequest.body = { ...validData, userId: "" }; + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "Missing required fields: userId, organizationId, description", + statusCode: 400, + }); + }); + + it("should return 400 when organizationId is missing", async () => { + mockRequest.body = { ...validData, organizationId: "" }; + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "Missing required fields: userId, organizationId, description", + statusCode: 400, + }); + }); + + it("should return 400 when description is missing", async () => { + mockRequest.body = { ...validData, description: "" }; + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "Missing required fields: userId, organizationId, description", + statusCode: 400, + }); + }); + + it("should handle NFTValidationError from use case", async () => { + const validationError = new NFTValidationError("Invalid NFT data"); + mockCreateNFTUseCase.execute.mockRejectedValue(validationError); + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "Invalid NFT data", + statusCode: 400, + details: undefined, + }); + }); + + it("should handle NFTDatabaseError from use case", async () => { + const dbError = new NFTDatabaseError("Database connection failed"); + mockCreateNFTUseCase.execute.mockRejectedValue(dbError); + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "DATABASE_ERROR", + message: "NFT database operation failed: Database connection failed", + statusCode: 500, + details: undefined, + }); + }); + }); + + describe("getNFTById", () => { + const nftId = "nft-123"; + + beforeEach(() => { + mockRequest = { + params: { id: nftId }, + }; + }); + + it("should return NFT when found", async () => { + const expectedNFT = { + id: nftId, + userId: "user-123", + organizationId: "org-123", + description: "Test NFT", + createdAt: new Date(), + }; + + mockGetNFTUseCase.execute.mockResolvedValue(expectedNFT); + + await nftController.getNFTById( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockGetNFTUseCase.execute).toHaveBeenCalledWith(nftId); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: expectedNFT, + message: "NFT retrieved successfully", + }); + }); + + it("should return 400 when id is missing", async () => { + mockRequest.params = {}; + + await nftController.getNFTById( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "NFT ID is required", + statusCode: 400, + }); + }); + + it("should handle NFTNotFoundError from use case", async () => { + const notFoundError = new NFTNotFoundError(nftId); + mockGetNFTUseCase.execute.mockRejectedValue(notFoundError); + + await nftController.getNFTById( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "NFT_NOT_FOUND", + message: `NFT with ID ${nftId} not found`, + statusCode: 404, + }); + }); + + it("should handle NFTValidationError from use case", async () => { + const validationError = new NFTValidationError("Invalid NFT ID"); + mockGetNFTUseCase.execute.mockRejectedValue(validationError); + + await nftController.getNFTById( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "Invalid NFT ID", + statusCode: 400, + details: undefined, + }); + }); + }); + + describe("getNFTsByUserId", () => { + const userId = "user-123"; + + beforeEach(() => { + mockRequest = { + params: { userId }, + query: { page: "1", pageSize: "10" }, + }; + }); + + it("should return NFTs when found", async () => { + const expectedResult = { + nfts: [ + { + id: "nft-1", + userId, + organizationId: "org-1", + description: "NFT 1", + createdAt: new Date(), + }, + { + id: "nft-2", + userId, + organizationId: "org-2", + description: "NFT 2", + createdAt: new Date(), + }, + ], + total: 2, + }; + + mockGetNFTByUserIdUseCase.execute.mockResolvedValue(expectedResult); + + await nftController.getNFTsByUserId( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockGetNFTByUserIdUseCase.execute).toHaveBeenCalledWith( + userId, + 1, + 10 + ); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + data: expectedResult, + message: "NFTs retrieved successfully", + pagination: { + page: 1, + pageSize: 10, + total: 2, + totalPages: 1, + }, + }); + }); + + it("should use default pagination values", async () => { + mockRequest.query = {}; + const expectedResult = { nfts: [], total: 0 }; + + mockGetNFTByUserIdUseCase.execute.mockResolvedValue(expectedResult); + + await nftController.getNFTsByUserId( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockGetNFTByUserIdUseCase.execute).toHaveBeenCalledWith( + userId, + 1, + 10 + ); + }); + + it("should return 400 when userId is missing", async () => { + mockRequest.params = {}; + + await nftController.getNFTsByUserId( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "User ID is required", + statusCode: 400, + }); + }); + + it("should handle NFTValidationError from use case", async () => { + const validationError = new NFTValidationError("Invalid pagination"); + mockGetNFTByUserIdUseCase.execute.mockRejectedValue(validationError); + + await nftController.getNFTsByUserId( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "Invalid pagination", + statusCode: 400, + details: undefined, + }); + }); + }); + + describe("deleteNFT", () => { + const nftId = "nft-123"; + + beforeEach(() => { + mockRequest = { + params: { id: nftId }, + }; + }); + + it("should delete NFT successfully", async () => { + mockDeleteNFTUseCase.execute.mockResolvedValue(); + + await nftController.deleteNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockDeleteNFTUseCase.execute).toHaveBeenCalledWith(nftId); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: true, + message: "NFT deleted successfully", + }); + }); + + it("should return 400 when id is missing", async () => { + mockRequest.params = {}; + + await nftController.deleteNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "NFT ID is required", + statusCode: 400, + }); + }); + + it("should handle NFTNotFoundError from use case", async () => { + const notFoundError = new NFTNotFoundError(nftId); + mockDeleteNFTUseCase.execute.mockRejectedValue(notFoundError); + + await nftController.deleteNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(404); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "NFT_NOT_FOUND", + message: `NFT with ID ${nftId} not found`, + statusCode: 404, + }); + }); + + it("should handle NFTValidationError from use case", async () => { + const validationError = new NFTValidationError("Invalid NFT ID"); + mockDeleteNFTUseCase.execute.mockRejectedValue(validationError); + + await nftController.deleteNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "VALIDATION_ERROR", + message: "Invalid NFT ID", + statusCode: 400, + details: undefined, + }); + }); + }); + + describe("error handling", () => { + it("should handle unknown errors with 500 status", async () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(); + const unknownError = new Error("Unknown error"); + + mockRequest = { body: {} }; + mockCreateNFTUseCase.execute.mockRejectedValue(unknownError); + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "INTERNAL_SERVER_ERROR", + message: "An unexpected error occurred", + statusCode: 500, + }); + + consoleSpy.mockRestore(); + }); + + it("should handle NFTAlreadyMintedError with 409 status", async () => { + const alreadyMintedError = new NFTAlreadyMintedError("nft-123"); + mockRequest = { + body: { + userId: "user-123", + organizationId: "org-123", + description: "test", + }, + }; + mockCreateNFTUseCase.execute.mockRejectedValue(alreadyMintedError); + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(409); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "NFT_ALREADY_MINTED", + message: "NFT with ID nft-123 is already minted", + statusCode: 409, + }); + }); + + it("should handle NFTMintingError with 500 status", async () => { + const mintingError = new NFTMintingError("Smart contract error"); + mockRequest = { + body: { + userId: "user-123", + organizationId: "org-123", + description: "test", + }, + }; + mockCreateNFTUseCase.execute.mockRejectedValue(mintingError); + + await nftController.createNFT( + mockRequest as Request, + mockResponse as Response + ); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + error: "MINTING_ERROR", + message: "NFT minting failed: Smart contract error", + statusCode: 500, + details: undefined, + }); + }); + }); +}); diff --git a/src/modules/nft/application/services/nft.service.ts b/src/modules/nft/application/services/nft.service.ts new file mode 100644 index 0000000..a330328 --- /dev/null +++ b/src/modules/nft/application/services/nft.service.ts @@ -0,0 +1,295 @@ +import { INFTRepository } from "../../domain/interfaces/nft.interface"; +import { NFTDomain as NFT } from "../../domain/entities/nft.entity"; +import { CreateNFTDto } from "../../dto/create-nft.dto"; +import { + NFTNotFoundError, + NFTValidationError, + NFTDatabaseError, + NFTAlreadyMintedError, + NFTMintingError, +} from "../../domain/exceptions"; + +export class NFTService { + constructor(private readonly nftRepository: INFTRepository) {} + + async createNFT(data: CreateNFTDto): Promise { + try { + // Validate input data + if (!data.userId || !data.organizationId || !data.description) { + throw new NFTValidationError( + "User ID, organization ID, and description are required", + { + providedData: data, + } + ); + } + + if (data.description.trim().length === 0) { + throw new NFTValidationError("Description cannot be empty"); + } + + // Create NFT domain object + const nft = new NFT( + Date.now().toString(), + data.userId, + data.organizationId, + data.description, + new Date() + ); + + // Save to repository + return await this.nftRepository.create(nft); + } catch (error) { + if (error instanceof NFTValidationError) { + throw error; + } + + if (error instanceof NFTDatabaseError) { + throw error; + } + + // Map other errors to domain exceptions + if (error instanceof Error) { + throw new NFTDatabaseError(`Failed to create NFT: ${error.message}`, { + originalError: error.message, + nftData: data, + }); + } + + throw new NFTDatabaseError("Failed to create NFT: Unknown error"); + } + } + + async getNFTById(id: string): Promise { + try { + if (!id || id.trim().length === 0) { + throw new NFTValidationError("NFT ID is required"); + } + + const nft = await this.nftRepository.findById(id); + + if (!nft) { + throw new NFTNotFoundError(id); + } + + return nft; + } catch (error) { + if ( + error instanceof NFTNotFoundError || + error instanceof NFTValidationError + ) { + throw error; + } + + if (error instanceof NFTDatabaseError) { + throw error; + } + + // Map other errors to domain exceptions + if (error instanceof Error) { + throw new NFTDatabaseError(`Failed to get NFT: ${error.message}`, { + originalError: error.message, + nftId: id, + }); + } + + throw new NFTDatabaseError("Failed to get NFT: Unknown error"); + } + } + + async getNFTsByUserId( + userId: string, + page: number = 1, + pageSize: number = 10 + ): Promise<{ nfts: NFT[]; total: number }> { + try { + if (!userId || userId.trim().length === 0) { + throw new NFTValidationError("User ID is required"); + } + + if (page < 1) { + throw new NFTValidationError("Page number must be greater than 0"); + } + + if (pageSize < 1 || pageSize > 100) { + throw new NFTValidationError("Page size must be between 1 and 100"); + } + + return await this.nftRepository.findByUserId(userId, page, pageSize); + } catch (error) { + if (error instanceof NFTValidationError) { + throw error; + } + + if (error instanceof NFTDatabaseError) { + throw error; + } + + // Map other errors to domain exceptions + if (error instanceof Error) { + throw new NFTDatabaseError( + `Failed to get NFTs by user ID: ${error.message}`, + { + originalError: error.message, + userId, + page, + pageSize, + } + ); + } + + throw new NFTDatabaseError( + "Failed to get NFTs by user ID: Unknown error" + ); + } + } + + async deleteNFT(id: string): Promise { + try { + if (!id || id.trim().length === 0) { + throw new NFTValidationError("NFT ID is required"); + } + + // Check if NFT exists before deleting + const existingNFT = await this.nftRepository.findById(id); + if (!existingNFT) { + throw new NFTNotFoundError(id); + } + + await this.nftRepository.delete(id); + } catch (error) { + if ( + error instanceof NFTNotFoundError || + error instanceof NFTValidationError + ) { + throw error; + } + + if (error instanceof NFTDatabaseError) { + throw error; + } + + // Map other errors to domain exceptions + if (error instanceof Error) { + throw new NFTDatabaseError(`Failed to delete NFT: ${error.message}`, { + originalError: error.message, + nftId: id, + }); + } + + throw new NFTDatabaseError("Failed to delete NFT: Unknown error"); + } + } + + async mintNFT( + id: string, + tokenId: string, + contractAddress: string, + metadataUri?: string + ): Promise { + try { + if (!id || id.trim().length === 0) { + throw new NFTValidationError("NFT ID is required"); + } + + if (!tokenId || !contractAddress) { + throw new NFTValidationError( + "Token ID and contract address are required for minting" + ); + } + + // Get existing NFT + const nft = await this.nftRepository.findById(id); + if (!nft) { + throw new NFTNotFoundError(id); + } + + // Check if already minted + if (nft.isMinted) { + throw new NFTAlreadyMintedError(id); + } + + // Update NFT with minting data + const updatedNFT = await this.nftRepository.update(id, { + tokenId, + contractAddress, + metadataUri, + isMinted: true, + }); + + return updatedNFT; + } catch (error) { + if ( + error instanceof NFTNotFoundError || + error instanceof NFTValidationError || + error instanceof NFTAlreadyMintedError + ) { + throw error; + } + + if (error instanceof NFTDatabaseError) { + throw error; + } + + // Map other errors to domain exceptions + if (error instanceof Error) { + throw new NFTMintingError(`Failed to mint NFT: ${error.message}`, { + originalError: error.message, + nftId: id, + tokenId, + contractAddress, + }); + } + + throw new NFTMintingError("Failed to mint NFT: Unknown error"); + } + } + + async updateNFTMetadata(id: string, metadataUri: string): Promise { + try { + if (!id || id.trim().length === 0) { + throw new NFTValidationError("NFT ID is required"); + } + + if (!metadataUri || metadataUri.trim().length === 0) { + throw new NFTValidationError("Metadata URI cannot be empty"); + } + + // Check if NFT exists + const existingNFT = await this.nftRepository.findById(id); + if (!existingNFT) { + throw new NFTNotFoundError(id); + } + + // Update metadata + return await this.nftRepository.update(id, { metadataUri }); + } catch (error) { + if ( + error instanceof NFTNotFoundError || + error instanceof NFTValidationError + ) { + throw error; + } + + if (error instanceof NFTDatabaseError) { + throw error; + } + + // Map other errors to domain exceptions + if (error instanceof Error) { + throw new NFTDatabaseError( + `Failed to update NFT metadata: ${error.message}`, + { + originalError: error.message, + nftId: id, + metadataUri, + } + ); + } + + throw new NFTDatabaseError( + "Failed to update NFT metadata: Unknown error" + ); + } + } +} diff --git a/src/modules/nft/domain/entities/nft.entity.ts b/src/modules/nft/domain/entities/nft.entity.ts index 1b0654f..52cc0ec 100644 --- a/src/modules/nft/domain/entities/nft.entity.ts +++ b/src/modules/nft/domain/entities/nft.entity.ts @@ -2,6 +2,11 @@ import { Entity, Column, ManyToOne, JoinColumn } from "typeorm"; import { BaseEntity } from "../../../shared/domain/entities/base.entity"; import { Organization } from "../../../organization/domain/entities/organization.entity"; import { User } from "@/modules/user/domain/entities/User.entity"; +import { + NFTAlreadyMintedError, + NFTValidationError, + NFTMetadataError, +} from "../exceptions"; @Entity("nfts") export class NFT extends BaseEntity { @@ -41,7 +46,13 @@ export class NFT extends BaseEntity { metadataUri?: string ): void { if (this.isMinted) { - throw new Error("NFT is already minted"); + throw new NFTAlreadyMintedError(this.id); + } + + if (!tokenId || !contractAddress) { + throw new NFTValidationError( + "Token ID and contract address are required for minting" + ); } this.tokenId = tokenId; @@ -51,6 +62,9 @@ export class NFT extends BaseEntity { } public updateMetadata(metadataUri: string): void { + if (!metadataUri || metadataUri.trim().length === 0) { + throw new NFTMetadataError("Metadata URI cannot be empty"); + } this.metadataUri = metadataUri; } diff --git a/src/modules/nft/domain/exceptions/index.ts b/src/modules/nft/domain/exceptions/index.ts new file mode 100644 index 0000000..eb8ab31 --- /dev/null +++ b/src/modules/nft/domain/exceptions/index.ts @@ -0,0 +1 @@ +export * from "./nft.exception"; diff --git a/src/modules/nft/domain/exceptions/nft.exception.ts b/src/modules/nft/domain/exceptions/nft.exception.ts new file mode 100644 index 0000000..99fbba1 --- /dev/null +++ b/src/modules/nft/domain/exceptions/nft.exception.ts @@ -0,0 +1,43 @@ +import { + ResourceNotFoundError, + ResourceConflictError, + ValidationError, + DatabaseError, + InternalServerError, +} from "../../../shared/application/errors/common.errors"; + +export class NFTNotFoundError extends ResourceNotFoundError { + constructor(nftId: string) { + super(`NFT with ID ${nftId} not found`); + } +} + +export class NFTAlreadyMintedError extends ResourceConflictError { + constructor(nftId: string) { + super(`NFT with ID ${nftId} is already minted`); + } +} + +export class NFTValidationError extends ValidationError { + constructor(message: string, details?: Record) { + super(message, details); + } +} + +export class NFTDatabaseError extends DatabaseError { + constructor(message: string, details?: Record) { + super(`NFT database operation failed: ${message}`, details); + } +} + +export class NFTMintingError extends InternalServerError { + constructor(message: string, details?: Record) { + super(`NFT minting failed: ${message}`, details); + } +} + +export class NFTMetadataError extends ValidationError { + constructor(message: string, details?: Record) { + super(`NFT metadata error: ${message}`, details); + } +} diff --git a/src/modules/nft/index.ts b/src/modules/nft/index.ts new file mode 100644 index 0000000..09adaa2 --- /dev/null +++ b/src/modules/nft/index.ts @@ -0,0 +1,22 @@ +// Domain +export * from "./domain/entities/nft.entity"; +export * from "./domain/interfaces/nft.interface"; +export * from "./domain/exceptions"; + +// Application +export * from "./application/services/nft.service"; +export * from "./use-cases/createNFT"; +export * from "./use-cases/getNFT"; +export * from "./use-cases/getNFTByUserId"; +export * from "./use-cases/deleteNFT"; + +// Infrastructure +export * from "./repositories/INFTRepository"; +export * from "./repositories/nft.repository"; + +// Presentation +export * from "./presentation/controllers/nft.controller"; + +// DTOs +export * from "./dto/create-nft.dto"; +export * from "./dto/response-nft.dto"; diff --git a/src/modules/nft/presentation/controllers/nft.controller.ts b/src/modules/nft/presentation/controllers/nft.controller.ts new file mode 100644 index 0000000..7d9a10f --- /dev/null +++ b/src/modules/nft/presentation/controllers/nft.controller.ts @@ -0,0 +1,244 @@ +import { Request, Response } from "express"; +import { CreateNFT } from "../../use-cases/createNFT"; +import { GetNFT } from "../../use-cases/getNFT"; +import { GetNFTByUserId } from "../../use-cases/getNFTByUserId"; +import { DeleteNFT } from "../../use-cases/deleteNFT"; +import { + NFTNotFoundError, + NFTValidationError, + NFTDatabaseError, + NFTAlreadyMintedError, + NFTMintingError, + NFTMetadataError, +} from "../../domain/exceptions"; +import { + ValidationError, + ResourceNotFoundError, + ResourceConflictError, + DatabaseError, +} from "../../../shared/application/errors/common.errors"; + +export class NFTController { + constructor( + private readonly createNFTUseCase: CreateNFT, + private readonly getNFTUseCase: GetNFT, + private readonly getNFTByUserIdUseCase: GetNFTByUserId, + private readonly deleteNFTUseCase: DeleteNFT + ) {} + + async createNFT(req: Request, res: Response): Promise { + try { + const { userId, organizationId, description } = req.body; + + if (!userId || !organizationId || !description) { + throw new ValidationError( + "Missing required fields: userId, organizationId, description" + ); + } + + const nft = await this.createNFTUseCase.execute({ + userId, + organizationId, + description, + }); + + res.status(201).json({ + success: true, + data: nft, + message: "NFT created successfully", + }); + } catch (error) { + this.handleError(error, res); + } + } + + async getNFTById(req: Request, res: Response): Promise { + try { + const { id } = req.params; + + if (!id) { + throw new ValidationError("NFT ID is required"); + } + + const nft = await this.getNFTUseCase.execute(id); + + res.status(200).json({ + success: true, + data: nft, + message: "NFT retrieved successfully", + }); + } catch (error) { + this.handleError(error, res); + } + } + + async getNFTsByUserId(req: Request, res: Response): Promise { + try { + const { userId } = req.params; + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 10; + + if (!userId) { + throw new ValidationError("User ID is required"); + } + + const result = await this.getNFTByUserIdUseCase.execute( + userId, + page, + pageSize + ); + + res.status(200).json({ + success: true, + data: result, + message: "NFTs retrieved successfully", + pagination: { + page, + pageSize, + total: result.total, + totalPages: Math.ceil(result.total / pageSize), + }, + }); + } catch (error) { + this.handleError(error, res); + } + } + + async deleteNFT(req: Request, res: Response): Promise { + try { + const { id } = req.params; + + if (!id) { + throw new ValidationError("NFT ID is required"); + } + + await this.deleteNFTUseCase.execute(id); + + res.status(200).json({ + success: true, + message: "NFT deleted successfully", + }); + } catch (error) { + this.handleError(error, res); + } + } + + private handleError(error: unknown, res: Response): void { + // Map domain exceptions to appropriate HTTP responses + if (error instanceof NFTNotFoundError) { + res.status(404).json({ + success: false, + error: "NFT_NOT_FOUND", + message: error.message, + statusCode: 404, + }); + return; + } + + if (error instanceof NFTValidationError) { + res.status(400).json({ + success: false, + error: "VALIDATION_ERROR", + message: error.message, + statusCode: 400, + details: error.details, + }); + return; + } + + if (error instanceof NFTAlreadyMintedError) { + res.status(409).json({ + success: false, + error: "NFT_ALREADY_MINTED", + message: error.message, + statusCode: 409, + }); + return; + } + + if (error instanceof NFTDatabaseError) { + res.status(500).json({ + success: false, + error: "DATABASE_ERROR", + message: error.message, + statusCode: 500, + details: error.details, + }); + return; + } + + if (error instanceof NFTMintingError) { + res.status(500).json({ + success: false, + error: "MINTING_ERROR", + message: error.message, + statusCode: 500, + details: error.details, + }); + return; + } + + if (error instanceof NFTMetadataError) { + res.status(400).json({ + success: false, + error: "METADATA_ERROR", + message: error.message, + statusCode: 400, + details: error.details, + }); + return; + } + + // Handle other domain exceptions + if (error instanceof ValidationError) { + res.status(400).json({ + success: false, + error: "VALIDATION_ERROR", + message: error.message, + statusCode: 400, + details: error.details, + }); + return; + } + + if (error instanceof ResourceNotFoundError) { + res.status(404).json({ + success: false, + error: "RESOURCE_NOT_FOUND", + message: error.message, + statusCode: 404, + }); + return; + } + + if (error instanceof ResourceConflictError) { + res.status(409).json({ + success: false, + error: "RESOURCE_CONFLICT", + message: error.message, + statusCode: 409, + }); + return; + } + + if (error instanceof DatabaseError) { + res.status(500).json({ + success: false, + error: "DATABASE_ERROR", + message: error.message, + statusCode: 500, + details: error.details, + }); + return; + } + + // Handle unknown errors + console.error("Unhandled error in NFT controller:", error); + res.status(500).json({ + success: false, + error: "INTERNAL_SERVER_ERROR", + message: "An unexpected error occurred", + statusCode: 500, + }); + } +} diff --git a/src/modules/nft/repositories/nft.repository.ts b/src/modules/nft/repositories/nft.repository.ts index 05c7bbb..435be2a 100644 --- a/src/modules/nft/repositories/nft.repository.ts +++ b/src/modules/nft/repositories/nft.repository.ts @@ -1,6 +1,7 @@ import { PrismaClient } from "@prisma/client"; import { INFTRepository } from "./INFTRepository"; import { NFTDomain as NFT } from "../domain/entities/nft.entity"; +import { NFTDatabaseError } from "../domain/exceptions"; // Define our own types based on the Prisma schema interface PrismaNFT { @@ -16,36 +17,59 @@ export class NFTRepository implements INFTRepository { private prisma = new PrismaClient(); async create(nft: NFT): Promise { - const newNFT = (await this.prisma.nFT.create({ - data: { - userId: nft.userId, - organizationId: nft.organizationId, - description: nft.description, - }, - })) as unknown as PrismaNFT; + try { + const newNFT = (await this.prisma.nFT.create({ + data: { + userId: nft.userId, + organizationId: nft.organizationId, + description: nft.description, + }, + })) as unknown as PrismaNFT; - return new NFT( - newNFT.id, - newNFT.userId, - newNFT.organizationId, - newNFT.description, - newNFT.createdAt - ); + return new NFT( + newNFT.id, + newNFT.userId, + newNFT.organizationId, + newNFT.description, + newNFT.createdAt + ); + } catch (error) { + if (error instanceof Error) { + throw new NFTDatabaseError(`Failed to create NFT: ${error.message}`, { + originalError: error.message, + nftData: { userId: nft.userId, organizationId: nft.organizationId }, + }); + } + throw new NFTDatabaseError("Failed to create NFT: Unknown error"); + } } async findById(id: string): Promise { - const nft = (await this.prisma.nFT.findUnique({ - where: { id }, - })) as unknown as PrismaNFT | null; - return nft - ? new NFT( - nft.id, - nft.userId, - nft.organizationId, - nft.description, - nft.createdAt - ) - : null; + try { + const nft = (await this.prisma.nFT.findUnique({ + where: { id }, + })) as unknown as PrismaNFT | null; + return nft + ? new NFT( + nft.id, + nft.userId, + nft.organizationId, + nft.description, + nft.createdAt + ) + : null; + } catch (error) { + if (error instanceof Error) { + throw new NFTDatabaseError( + `Failed to find NFT by ID: ${error.message}`, + { + originalError: error.message, + nftId: id, + } + ); + } + throw new NFTDatabaseError("Failed to find NFT by ID: Unknown error"); + } } async findByUserId( @@ -53,49 +77,87 @@ export class NFTRepository implements INFTRepository { page: number, pageSize: number ): Promise<{ nfts: NFT[]; total: number }> { - const skip = (page - 1) * pageSize; + try { + const skip = (page - 1) * pageSize; - const [nfts, total] = await Promise.all([ - this.prisma.nFT.findMany({ - where: { userId }, - skip, - take: pageSize, - orderBy: { createdAt: "desc" }, - }), - this.prisma.nFT.count({ where: { userId } }), - ]); + const [nfts, total] = await Promise.all([ + this.prisma.nFT.findMany({ + where: { userId }, + skip, + take: pageSize, + orderBy: { createdAt: "desc" }, + }), + this.prisma.nFT.count({ where: { userId } }), + ]); - return { - nfts: (nfts as unknown as PrismaNFT[]).map( - (nft) => - new NFT( - nft.id, - nft.userId, - nft.organizationId, - nft.description, - nft.createdAt - ) - ), - total, - }; + return { + nfts: (nfts as unknown as PrismaNFT[]).map( + (nft) => + new NFT( + nft.id, + nft.userId, + nft.organizationId, + nft.description, + nft.createdAt + ) + ), + total, + }; + } catch (error) { + if (error instanceof Error) { + throw new NFTDatabaseError( + `Failed to find NFTs by user ID: ${error.message}`, + { + originalError: error.message, + userId, + page, + pageSize, + } + ); + } + throw new NFTDatabaseError( + "Failed to find NFTs by user ID: Unknown error" + ); + } } async update(id: string, nft: Partial): Promise { - const updatedNFT = (await this.prisma.nFT.update({ - where: { id }, - data: nft, - })) as unknown as PrismaNFT; + try { + const updatedNFT = (await this.prisma.nFT.update({ + where: { id }, + data: nft, + })) as unknown as PrismaNFT; - return new NFT( - updatedNFT.id, - updatedNFT.userId, - updatedNFT.organizationId, - updatedNFT.description, - updatedNFT.createdAt - ); + return new NFT( + updatedNFT.id, + updatedNFT.userId, + updatedNFT.organizationId, + updatedNFT.description, + updatedNFT.createdAt + ); + } catch (error) { + if (error instanceof Error) { + throw new NFTDatabaseError(`Failed to update NFT: ${error.message}`, { + originalError: error.message, + nftId: id, + updateData: nft, + }); + } + throw new NFTDatabaseError("Failed to update NFT: Unknown error"); + } } async delete(id: string): Promise { - await this.prisma.nFT.delete({ where: { id } }); + try { + await this.prisma.nFT.delete({ where: { id } }); + } catch (error) { + if (error instanceof Error) { + throw new NFTDatabaseError(`Failed to delete NFT: ${error.message}`, { + originalError: error.message, + nftId: id, + }); + } + throw new NFTDatabaseError("Failed to delete NFT: Unknown error"); + } } } diff --git a/src/modules/nft/use-cases/createNFT.ts b/src/modules/nft/use-cases/createNFT.ts index 3971afb..d4a0d94 100644 --- a/src/modules/nft/use-cases/createNFT.ts +++ b/src/modules/nft/use-cases/createNFT.ts @@ -1,11 +1,26 @@ import { INFTRepository } from "../repositories/INFTRepository"; import { NFTDomain as NFT } from "../domain/entities/nft.entity"; import { CreateNFTDto } from "../dto/create-nft.dto"; +import { NFTValidationError } from "../domain/exceptions"; export class CreateNFT { constructor(private readonly nftRepository: INFTRepository) {} async execute(data: CreateNFTDto): Promise { + // Validate input data + if (!data.userId || !data.organizationId || !data.description) { + throw new NFTValidationError( + "User ID, organization ID, and description are required", + { + providedData: data, + } + ); + } + + if (data.description.trim().length === 0) { + throw new NFTValidationError("Description cannot be empty"); + } + const nft = new NFT( Date.now().toString(), data.userId, @@ -13,6 +28,7 @@ export class CreateNFT { data.description, new Date() ); + return await this.nftRepository.create(nft); } } diff --git a/src/modules/nft/use-cases/deleteNFT.ts b/src/modules/nft/use-cases/deleteNFT.ts index c026e53..0726d1a 100644 --- a/src/modules/nft/use-cases/deleteNFT.ts +++ b/src/modules/nft/use-cases/deleteNFT.ts @@ -1,9 +1,20 @@ import { INFTRepository } from "../repositories/INFTRepository"; +import { NFTNotFoundError, NFTValidationError } from "../domain/exceptions"; export class DeleteNFT { constructor(private readonly nftRepository: INFTRepository) {} async execute(id: string) { + if (!id || id.trim().length === 0) { + throw new NFTValidationError("NFT ID is required"); + } + + // Check if NFT exists before deleting + const existingNFT = await this.nftRepository.findById(id); + if (!existingNFT) { + throw new NFTNotFoundError(id); + } + return await this.nftRepository.delete(id); } } diff --git a/src/modules/nft/use-cases/getNFT.ts b/src/modules/nft/use-cases/getNFT.ts index 16b9f00..2b0c265 100644 --- a/src/modules/nft/use-cases/getNFT.ts +++ b/src/modules/nft/use-cases/getNFT.ts @@ -1,9 +1,20 @@ import { INFTRepository } from "../repositories/INFTRepository"; +import { NFTNotFoundError, NFTValidationError } from "../domain/exceptions"; export class GetNFT { constructor(private readonly nftRepository: INFTRepository) {} async execute(id: string) { - return await this.nftRepository.findById(id); + if (!id || id.trim().length === 0) { + throw new NFTValidationError("NFT ID is required"); + } + + const nft = await this.nftRepository.findById(id); + + if (!nft) { + throw new NFTNotFoundError(id); + } + + return nft; } } diff --git a/src/modules/nft/use-cases/getNFTByUserId.ts b/src/modules/nft/use-cases/getNFTByUserId.ts index 9caa809..674f364 100644 --- a/src/modules/nft/use-cases/getNFTByUserId.ts +++ b/src/modules/nft/use-cases/getNFTByUserId.ts @@ -1,9 +1,22 @@ import { INFTRepository } from "../repositories/INFTRepository"; +import { NFTValidationError } from "../domain/exceptions"; export class GetNFTByUserId { constructor(private readonly nftRepository: INFTRepository) {} async execute(id: string, page: number, pageSize: number) { + if (!id || id.trim().length === 0) { + throw new NFTValidationError("User ID is required"); + } + + if (page < 1) { + throw new NFTValidationError("Page number must be greater than 0"); + } + + if (pageSize < 1 || pageSize > 100) { + throw new NFTValidationError("Page size must be between 1 and 100"); + } + return await this.nftRepository.findByUserId(id, page, pageSize); } }