From a35a975373d39d6343765d3e5c96a702a66c85d2 Mon Sep 17 00:00:00 2001 From: ayshadogo Date: Sun, 22 Feb 2026 21:09:19 +0100 Subject: [PATCH 1/3] Build Categories Module with CRUD Endpoints --- backend/package-lock.json | 36 +---- .../categories/categories.controller.spec.ts | 97 ++++++++++++++ .../src/categories/categories.service.spec.ts | 123 ++++++++++++++++++ .../src/categories/dto/create-category.dto.ts | 2 + 4 files changed, 227 insertions(+), 31 deletions(-) create mode 100644 backend/src/categories/categories.controller.spec.ts create mode 100644 backend/src/categories/categories.service.spec.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 7f7d8b09..0dbde9c0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1275,7 +1275,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2848,7 +2847,6 @@ "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.20.tgz", "integrity": "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -2894,7 +2892,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz", "integrity": "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==", "hasInstallScript": true, - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -2988,7 +2985,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", "integrity": "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.3", "cors": "2.8.5", @@ -3170,7 +3166,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.15.tgz", "integrity": "sha512-OmCUJwvtagzXfMVko595O98UI3M9zg+URL+/HV7vd3QPMCZ3uGCKSq15YYJ99LHJn9NyK4e4Szm2KnHtUg2QzA==", "license": "MIT", - "peer": true, "dependencies": { "iterare": "1.2.1", "object-hash": "3.0.0", @@ -3368,7 +3363,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz", "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -4367,7 +4361,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4531,7 +4524,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4771,7 +4763,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.44.1.tgz", "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/types": "8.44.1", @@ -5149,7 +5140,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5162,6 +5152,7 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, + "peer": true, "engines": { "node": ">=10.13.0" }, @@ -5195,7 +5186,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -5851,7 +5841,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5952,7 +5941,6 @@ "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz", "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "^4.9.0", "get-port": "^5.1.1", @@ -5999,7 +5987,6 @@ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.8.tgz", "integrity": "sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==", "license": "MIT", - "peer": true, "dependencies": { "@cacheable/utils": "^2.3.3", "keyv": "^5.5.5" @@ -6031,7 +6018,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -6280,15 +6266,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7173,7 +7157,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7229,7 +7212,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7542,7 +7524,6 @@ "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8881,7 +8862,6 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -9768,7 +9748,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -10739,7 +10718,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -10869,7 +10847,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -11130,7 +11107,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11494,7 +11470,6 @@ "resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz", "integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==", "license": "MIT", - "peer": true, "dependencies": { "@redis/bloom": "5.10.0", "@redis/client": "5.10.0", @@ -11816,7 +11791,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12908,7 +12882,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13059,7 +13032,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz", "integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^3.17.0", @@ -13211,7 +13183,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13557,6 +13528,7 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -13570,6 +13542,7 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, + "peer": true, "engines": { "node": ">=4.0" } @@ -13579,6 +13552,7 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", diff --git a/backend/src/categories/categories.controller.spec.ts b/backend/src/categories/categories.controller.spec.ts new file mode 100644 index 00000000..c59972a3 --- /dev/null +++ b/backend/src/categories/categories.controller.spec.ts @@ -0,0 +1,97 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CategoriesController } from './categories.controller'; +import { CategoriesService } from './categories.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +const mockCategory = { id: 'uuid-1', name: 'Electronics' }; +const mockCategoryWithCount = { ...mockCategory, assetCount: 5 }; + +const mockService = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + remove: jest.fn(), +}; + +describe('CategoriesController', () => { + let controller: CategoriesController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CategoriesController], + providers: [{ provide: CategoriesService, useValue: mockService }], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(CategoriesController); + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return all categories with asset counts', async () => { + mockService.findAll.mockResolvedValue([mockCategoryWithCount]); + + const result = await controller.findAll(); + + expect(mockService.findAll).toHaveBeenCalledTimes(1); + expect(result).toEqual([mockCategoryWithCount]); + }); + }); + + describe('findOne', () => { + it('should return a single category by id', async () => { + mockService.findOne.mockResolvedValue(mockCategory); + + const result = await controller.findOne('uuid-1'); + + expect(mockService.findOne).toHaveBeenCalledWith('uuid-1'); + expect(result).toEqual(mockCategory); + }); + + it('should propagate exceptions from the service', async () => { + mockService.findOne.mockRejectedValue(new Error('Category not found')); + + await expect(controller.findOne('missing-id')).rejects.toThrow('Category not found'); + }); + }); + + describe('create', () => { + const dto = { name: 'Electronics' }; + + it('should create and return a new category', async () => { + mockService.create.mockResolvedValue(mockCategory); + + const result = await controller.create(dto); + + expect(mockService.create).toHaveBeenCalledWith(dto); + expect(result).toEqual(mockCategory); + }); + + it('should propagate conflict exceptions from the service', async () => { + mockService.create.mockRejectedValue(new Error('A category with this name already exists')); + + await expect(controller.create(dto)).rejects.toThrow( + 'A category with this name already exists', + ); + }); + }); + + describe('remove', () => { + it('should call service.remove with the correct id', async () => { + mockService.remove.mockResolvedValue(undefined); + + const result = await controller.remove('uuid-1'); + + expect(mockService.remove).toHaveBeenCalledWith('uuid-1'); + expect(result).toBeUndefined(); + }); + + it('should propagate exceptions from the service', async () => { + mockService.remove.mockRejectedValue(new Error('Category not found')); + + await expect(controller.remove('missing-id')).rejects.toThrow('Category not found'); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/categories/categories.service.spec.ts b/backend/src/categories/categories.service.spec.ts new file mode 100644 index 00000000..fe50a500 --- /dev/null +++ b/backend/src/categories/categories.service.spec.ts @@ -0,0 +1,123 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotFoundException, ConflictException } from '@nestjs/common'; +import { CategoriesService } from './categories.service'; +import { Category } from './category.entity'; + +const mockCategory: Category = { + id: 'uuid-1', + name: 'Electronics', +} as Category; + +const mockRepo = { + query: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), +}; + +describe('CategoriesService', () => { + let service: CategoriesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CategoriesService, + { provide: getRepositoryToken(Category), useValue: mockRepo }, + ], + }).compile(); + + service = module.get(CategoriesService); + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return categories with numeric assetCount', async () => { + const rawRows = [ + { ...mockCategory, assetCount: '3' }, + { id: 'uuid-2', name: 'Furniture', assetCount: '0' }, + ]; + mockRepo.query.mockResolvedValue(rawRows); + + const result = await service.findAll(); + + expect(mockRepo.query).toHaveBeenCalledTimes(1); + expect(result).toEqual([ + { ...mockCategory, assetCount: 3 }, + { id: 'uuid-2', name: 'Furniture', assetCount: 0 }, + ]); + expect(typeof result[0].assetCount).toBe('number'); + }); + + it('should return an empty array when no categories exist', async () => { + mockRepo.query.mockResolvedValue([]); + const result = await service.findAll(); + expect(result).toEqual([]); + }); + }); + + describe('findOne', () => { + it('should return a category when found', async () => { + mockRepo.findOne.mockResolvedValue(mockCategory); + + const result = await service.findOne('uuid-1'); + + expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(result).toEqual(mockCategory); + }); + + it('should throw NotFoundException when category does not exist', async () => { + mockRepo.findOne.mockResolvedValue(null); + + await expect(service.findOne('missing-id')).rejects.toThrow(NotFoundException); + await expect(service.findOne('missing-id')).rejects.toThrow('Category not found'); + }); + }); + + describe('create', () => { + const dto = { name: 'Electronics' }; + + it('should create and return a new category', async () => { + mockRepo.findOne.mockResolvedValue(null); + mockRepo.create.mockReturnValue(mockCategory); + mockRepo.save.mockResolvedValue(mockCategory); + + const result = await service.create(dto); + + expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { name: dto.name } }); + expect(mockRepo.create).toHaveBeenCalledWith(dto); + expect(mockRepo.save).toHaveBeenCalledWith(mockCategory); + expect(result).toEqual(mockCategory); + }); + + it('should throw ConflictException when a category with the same name exists', async () => { + mockRepo.findOne.mockResolvedValue(mockCategory); + + await expect(service.create(dto)).rejects.toThrow(ConflictException); + await expect(service.create(dto)).rejects.toThrow( + 'A category with this name already exists', + ); + expect(mockRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('remove', () => { + it('should remove the category successfully', async () => { + mockRepo.findOne.mockResolvedValue(mockCategory); + mockRepo.remove.mockResolvedValue(undefined); + + await service.remove('uuid-1'); + + expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: 'uuid-1' } }); + expect(mockRepo.remove).toHaveBeenCalledWith(mockCategory); + }); + + it('should throw NotFoundException if category does not exist', async () => { + mockRepo.findOne.mockResolvedValue(null); + + await expect(service.remove('missing-id')).rejects.toThrow(NotFoundException); + expect(mockRepo.remove).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/categories/dto/create-category.dto.ts b/backend/src/categories/dto/create-category.dto.ts index a1692d84..9501e61c 100644 --- a/backend/src/categories/dto/create-category.dto.ts +++ b/backend/src/categories/dto/create-category.dto.ts @@ -3,12 +3,14 @@ import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator'; export class CreateCategoryDto { @ApiProperty({ example: 'Laptop' }) + @ApiDescription('The name of the category') @IsString() @IsNotEmpty() @MaxLength(100) name: string; @ApiPropertyOptional({ example: 'Portable computing devices' }) + @ApiDescription('A brief description of the category') @IsString() @IsOptional() @MaxLength(500) From 90c652f349910d4beab3fc3680087f9d6f72a55e Mon Sep 17 00:00:00 2001 From: ayshadogo Date: Sun, 22 Feb 2026 21:12:56 +0100 Subject: [PATCH 2/3] Build Reports Module: Asset Summary Statistics --- backend/src/reports/reports.controller.ts | 18 +++++++ backend/src/reports/reports.module.ts | 12 +++++ backend/src/reports/reports.service.ts | 66 +++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 backend/src/reports/reports.controller.ts create mode 100644 backend/src/reports/reports.module.ts create mode 100644 backend/src/reports/reports.service.ts diff --git a/backend/src/reports/reports.controller.ts b/backend/src/reports/reports.controller.ts new file mode 100644 index 00000000..89f50e6b --- /dev/null +++ b/backend/src/reports/reports.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ReportsService } from './reports.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('Reports') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('reports') +export class ReportsController { + constructor(private readonly service: ReportsService) {} + + @Get('summary') + @ApiOperation({ summary: 'Get asset summary statistics' }) + getSummary() { + return this.service.getSummary(); + } +} diff --git a/backend/src/reports/reports.module.ts b/backend/src/reports/reports.module.ts new file mode 100644 index 00000000..95f7333a --- /dev/null +++ b/backend/src/reports/reports.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from '../assets/asset.entity'; +import { ReportsService } from './reports.service'; +import { ReportsController } from './reports.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset])], + controllers: [ReportsController], + providers: [ReportsService], +}) +export class ReportsModule {} diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts new file mode 100644 index 00000000..5c0289bc --- /dev/null +++ b/backend/src/reports/reports.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from '../assets/asset.entity'; +import { AssetStatus } from '../assets/enums'; + +@Injectable() +export class ReportsService { + constructor( + @InjectRepository(Asset) + private readonly assetsRepo: Repository, + ) {} + + async getSummary() { + const total = await this.assetsRepo.count(); + + // By status + const statusRows = await this.assetsRepo + .createQueryBuilder('a') + .select('a.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('a.status') + .getRawMany<{ status: string; count: string }>(); + + const byStatus = Object.values(AssetStatus).reduce( + (acc, s) => { acc[s] = 0; return acc; }, + {} as Record, + ); + for (const { status, count } of statusRows) { + byStatus[status as AssetStatus] = Number(count); + } + + // By category + const byCategory = await this.assetsRepo + .createQueryBuilder('a') + .leftJoin('a.category', 'c') + .select('COALESCE(c.name, :uncategorised)', 'name') + .setParameter('uncategorised', 'Uncategorised') + .addSelect('COUNT(*)', 'count') + .groupBy('c.name') + .getRawMany<{ name: string; count: string }>() + .then((rows) => rows.map((r) => ({ name: r.name, count: Number(r.count) }))); + + // By department + const byDepartment = await this.assetsRepo + .createQueryBuilder('a') + .leftJoin('a.department', 'd') + .select('COALESCE(d.name, :unassigned)', 'name') + .setParameter('unassigned', 'Unassigned') + .addSelect('COUNT(*)', 'count') + .groupBy('d.name') + .getRawMany<{ name: string; count: string }>() + .then((rows) => rows.map((r) => ({ name: r.name, count: Number(r.count) }))); + + // Recent (last 5 created) + const recent = await this.assetsRepo + .createQueryBuilder('a') + .leftJoinAndSelect('a.category', 'c') + .leftJoinAndSelect('a.department', 'd') + .orderBy('a.createdAt', 'DESC') + .take(5) + .getMany(); + + return { total, byStatus, byCategory, byDepartment, recent }; + } +} From 7d2ab17f96d9b93c363eb1ba884f331e1b14f50c Mon Sep 17 00:00:00 2001 From: ayshadogo Date: Sun, 22 Feb 2026 21:16:45 +0100 Subject: [PATCH 3/3] Implement Stellar/Soroban Blockchain Integration Service --- backend/src/stellar/stellar.module.ts | 10 ++ backend/src/stellar/stellar.service.ts | 203 +++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 backend/src/stellar/stellar.module.ts create mode 100644 backend/src/stellar/stellar.service.ts diff --git a/backend/src/stellar/stellar.module.ts b/backend/src/stellar/stellar.module.ts new file mode 100644 index 00000000..ac0ca0df --- /dev/null +++ b/backend/src/stellar/stellar.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { StellarService } from './stellar.service'; + +@Module({ + imports: [ConfigModule], + providers: [StellarService], + exports: [StellarService], +}) +export class StellarModule {} diff --git a/backend/src/stellar/stellar.service.ts b/backend/src/stellar/stellar.service.ts new file mode 100644 index 00000000..c86c6096 --- /dev/null +++ b/backend/src/stellar/stellar.service.ts @@ -0,0 +1,203 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { + Keypair, + rpc, + TransactionBuilder, + Networks, + BASE_FEE, + xdr, + Address, + nativeToScVal, + Contract, +} from '@stellar/stellar-sdk'; +import { Asset } from '../assets/asset.entity'; +import { AssetStatus } from '../assets/enums'; + +@Injectable() +export class StellarService implements OnModuleInit { + private readonly logger = new Logger(StellarService.name); + private enabled = false; + private keypair: Keypair; + private server: rpc.Server; + private contractId: string; + private networkPassphrase: string; + + constructor(private readonly configService: ConfigService) {} + + onModuleInit(): void { + this.enabled = this.configService.get('STELLAR_ENABLED') === 'true'; + if (!this.enabled) { + this.logger.log('Stellar integration is disabled (STELLAR_ENABLED != true)'); + return; + } + + const secretKey = this.configService.get('STELLAR_SECRET_KEY'); + const rpcUrl = this.configService.get('STELLAR_RPC_URL', 'https://soroban-testnet.stellar.org'); + this.contractId = this.configService.get('STELLAR_CONTRACT_ID'); + this.networkPassphrase = this.configService.get( + 'STELLAR_NETWORK_PASSPHRASE', + Networks.TESTNET, + ); + + if (!secretKey || !this.contractId) { + this.logger.error('STELLAR_SECRET_KEY and STELLAR_CONTRACT_ID must be set when STELLAR_ENABLED=true'); + this.enabled = false; + return; + } + + this.keypair = Keypair.fromSecret(secretKey); + this.server = new rpc.Server(rpcUrl, { allowHttp: false }); + this.logger.log(`Stellar integration enabled. Public key: ${this.keypair.publicKey()}`); + } + + get isEnabled(): boolean { + return this.enabled; + } + + /** + * Derives a deterministic 32-byte asset ID from the DB UUID using SHA-256. + * Returns both the Buffer and its hex string. + */ + deriveAssetId(uuid: string): { buffer: Buffer; hex: string } { + const buffer = crypto.createHash('sha256').update(uuid).digest(); + return { buffer, hex: buffer.toString('hex') }; + } + + /** + * Registers an asset on the Soroban contract. + * Returns the confirmed transaction hash. + */ + async registerAsset(asset: Asset): Promise { + const { buffer: assetIdBuffer } = this.deriveAssetId(asset.id); + + // Map backend AssetStatus → Soroban enum variant string + const sorobanStatus = this.mapStatus(asset.status); + + // Build the asset ScVal (ScvMap, alphabetically sorted keys) + const purchasePrice = asset.purchasePrice ? Math.round(Number(asset.purchasePrice) * 100) : 0; + const now = BigInt(Math.floor(Date.now() / 1000)); + + const assetScVal = xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('category'), + val: nativeToScVal(asset.category?.name ?? '', { type: 'string' }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('custom_attributes'), + val: xdr.ScVal.scvVec([]), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('description'), + val: nativeToScVal(asset.description ?? '', { type: 'string' }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('id'), + val: xdr.ScVal.scvBytes(assetIdBuffer), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('last_transfer_timestamp'), + val: nativeToScVal(0n, { type: 'u64' }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('metadata_uri'), + val: nativeToScVal('', { type: 'string' }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('name'), + val: nativeToScVal(asset.name, { type: 'string' }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('owner'), + val: new Address(this.keypair.publicKey()).toScVal(), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('purchase_value'), + val: nativeToScVal(BigInt(purchasePrice), { type: 'i128' }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('registration_timestamp'), + val: nativeToScVal(now, { type: 'u64' }), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol('status'), + val: xdr.ScVal.scvVec([xdr.ScVal.scvSymbol(sorobanStatus)]), + }), + ]); + + // Fetch the source account + const account = await this.server.getAccount(this.keypair.publicKey()); + + // Build the transaction + const contract = new Contract(this.contractId); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: this.networkPassphrase, + }) + .addOperation(contract.call('register_asset', assetScVal)) + .setTimeout(300) + .build(); + + // Simulate to get the footprint + const simResult = await this.server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`); + } + + // Assemble (applies footprint + auth entries) + const assembled = rpc.assembleTransaction(tx, simResult).build(); + + // Sign + assembled.sign(this.keypair); + + // Submit + const sendResult = await this.server.sendTransaction(assembled); + if (sendResult.status === 'ERROR') { + throw new Error(`Transaction submission failed: ${JSON.stringify(sendResult.errorResult)}`); + } + + const txHash = sendResult.hash; + this.logger.log(`Transaction submitted: ${txHash}, polling for confirmation...`); + + // Poll for finality (3s interval, 20 attempts ≈ 60s) + return this.pollForConfirmation(txHash); + } + + private async pollForConfirmation(txHash: string): Promise { + const MAX_ATTEMPTS = 20; + const INTERVAL_MS = 3000; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + await this.sleep(INTERVAL_MS); + const result = await this.server.getTransaction(txHash); + + if (result.status === rpc.Api.GetTransactionStatus.SUCCESS) { + this.logger.log(`Transaction confirmed: ${txHash}`); + return txHash; + } + + if (result.status === rpc.Api.GetTransactionStatus.FAILED) { + throw new Error(`Transaction failed on-chain: ${txHash}`); + } + + // MISSING or NOT_FOUND — still pending + this.logger.debug(`Poll attempt ${attempt}/${MAX_ATTEMPTS}: status=${result.status}`); + } + + throw new Error(`Transaction not confirmed after ${MAX_ATTEMPTS} attempts: ${txHash}`); + } + + private mapStatus(status: AssetStatus): string { + switch (status) { + case AssetStatus.RETIRED: + return 'Retired'; + default: + return 'Active'; + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +}