diff --git a/backend/package-lock.json b/backend/package-lock.json index a7b8393c..682691e4 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -74,7 +74,7 @@ "@types/bull": "^3.15.9", "@types/cache-manager": "^4.0.6", "@types/express": "^5.0.0", - "@types/jest": "^29.5.2", + "@types/jest": "^29.5.14", "@types/json2csv": "^5.0.7", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.19.30", diff --git a/backend/package.json b/backend/package.json index a8da7d19..8de384d6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,7 +42,6 @@ "@nestjs/terminus": "^10.2.3", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^10.0.2", - "helmet": "^8.0.0", "@nestjs/websockets": "^10.4.15", "@stellar/stellar-sdk": "^14.5.0", "@types/multer": "^2.0.0", @@ -59,6 +58,7 @@ "class-validator": "^0.14.3", "date-fns": "^4.1.0", "exceljs": "^4.4.0", + "helmet": "^8.0.0", "json2csv": "^6.0.0-alpha.2", "multer": "^2.0.2", "nestjs-i18n": "^10.5.1", @@ -90,7 +90,7 @@ "@types/bull": "^3.15.9", "@types/cache-manager": "^4.0.6", "@types/express": "^5.0.0", - "@types/jest": "^29.5.2", + "@types/jest": "^29.5.14", "@types/json2csv": "^5.0.7", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.19.30", diff --git a/backend/src/femaleotaku.spec.ts b/backend/src/femaleotaku.spec.ts deleted file mode 100644 index e6e90e31..00000000 --- a/backend/src/femaleotaku.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { LocationsService } from './locations/locations.service'; -import { CategoriesService } from './categories/categories.service'; -import { - AssetLifecycleService, - AssetStatus, -} from './assets/asset-lifecycle.service'; - -describe('femaleotaku Modules (BE-88, BE-87, BE-85, BE-84)', () => { - it('LocationsService creates location', async () => { - const mockRepo = { - create: jest.fn().mockImplementation((dto) => dto), - save: jest - .fn() - .mockImplementation((dto) => Promise.resolve({ id: 'loc-1', ...dto })), - }; - const mockAssetRepo = { - count: jest.fn().mockResolvedValue(0), - }; - const service = new LocationsService(mockRepo as any, mockAssetRepo as any); - const loc = await service.create({ name: 'Building A', code: 'BLD-A' }); - expect(loc.name).toBe('Building A'); - }); - - it('CategoriesService manages depreciation defaults', async () => { - const mockRepo = { - create: jest.fn().mockImplementation((dto) => dto), - save: jest - .fn() - .mockImplementation((dto) => Promise.resolve({ id: 'cat-1', ...dto })), - }; - const service = new CategoriesService(mockRepo as any); - const cat = await service.create({ - name: 'Laptops', - code: 'LAP', - defaultDepreciationRate: 20, - defaultUsefulLifeMonths: 36, - }); - expect(cat.defaultUsefulLifeMonths).toBe(36); - }); - - it('AssetLifecycleService validates state transitions and records history', () => { - const service = new AssetLifecycleService(); - expect( - service.validateTransition(AssetStatus.AVAILABLE, AssetStatus.ASSIGNED), - ).toBe(true); - expect(() => - service.validateTransition(AssetStatus.DISPOSED, AssetStatus.ASSIGNED), - ).toThrow('Cannot transition asset status from DISPOSED to ASSIGNED'); - - const history = service.recordHistory('asset-1', { - eventType: 'STATUS_CHANGED', - actorUserId: 'user-1', - note: 'Assigned to Jane', - }); - expect(history.id).toBeDefined(); - expect(service.getHistory('asset-1').length).toBe(1); - }); -}); diff --git a/backend/src/locations/locations.service.spec.ts b/backend/src/locations/locations.service.spec.ts new file mode 100644 index 00000000..28d8a178 --- /dev/null +++ b/backend/src/locations/locations.service.spec.ts @@ -0,0 +1,333 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { LocationsService } from './locations.service'; +import { Location } from './entities/location.entity'; +import { Asset } from '../assets/entities/asset.entity'; + +type MockRepo = { + find: jest.Mock; + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + remove: jest.Mock; + count: jest.Mock; + createQueryBuilder: jest.Mock; +}; + +const createMockQueryBuilder = (rawResult: any[]) => ({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(rawResult), +}); + +describe('LocationsService', () => { + let service: LocationsService; + let locationRepo: MockRepo; + let assetRepo: MockRepo; + + const makeLocation = (overrides: Partial = {}): Location => + ({ + id: 'loc-1', + name: 'Warehouse A', + parentLocationId: null, + ...overrides, + }) as Location; + + beforeEach(async () => { + locationRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + assetRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LocationsService, + { provide: getRepositoryToken(Location), useValue: locationRepo }, + { provide: getRepositoryToken(Asset), useValue: assetRepo }, + ], + }).compile(); + + service = module.get(LocationsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('findAll', () => { + it('attaches direct asset counts to each location', async () => { + const locations = [ + makeLocation({ id: 'loc-1' }), + makeLocation({ id: 'loc-2' }), + ]; + locationRepo.find.mockResolvedValue(locations); + assetRepo.createQueryBuilder.mockReturnValue( + createMockQueryBuilder([ + { locationId: 'loc-1', count: '3' }, + { locationId: 'loc-2', count: '5' }, + ]), + ); + + const result = await service.findAll(); + + expect(result.find((l) => l.id === 'loc-1')?.assetCount).toBe(3); + expect(result.find((l) => l.id === 'loc-2')?.assetCount).toBe(5); + }); + + it('defaults assetCount to 0 for locations with no direct assets', async () => { + const locations = [makeLocation({ id: 'loc-1' })]; + locationRepo.find.mockResolvedValue(locations); + assetRepo.createQueryBuilder.mockReturnValue(createMockQueryBuilder([])); + + const result = await service.findAll(); + + expect(result[0].assetCount).toBe(0); + expect(result[0].totalAssetCount).toBe(0); + }); + + it('rolls up totalAssetCount from descendants for a parent/child hierarchy', async () => { + // root -> child -> grandchild + const root = makeLocation({ id: 'root', parentLocationId: null }); + const child = makeLocation({ id: 'child', parentLocationId: 'root' }); + const grandchild = makeLocation({ + id: 'grandchild', + parentLocationId: 'child', + }); + locationRepo.find.mockResolvedValue([root, child, grandchild]); + + assetRepo.createQueryBuilder.mockReturnValue( + createMockQueryBuilder([ + { locationId: 'root', count: '1' }, + { locationId: 'child', count: '2' }, + { locationId: 'grandchild', count: '4' }, + ]), + ); + + const result = await service.findAll(); + const byId = new Map(result.map((l) => [l.id, l])); + + expect(byId.get('grandchild')?.totalAssetCount).toBe(4); + expect(byId.get('child')?.totalAssetCount).toBe(6); // 2 + 4 + expect(byId.get('root')?.totalAssetCount).toBe(7); // 1 + 2 + 4 + // direct counts remain unaffected by rollup + expect(byId.get('root')?.assetCount).toBe(1); + }); + + it('handles multiple sibling subtrees independently', async () => { + const root = makeLocation({ id: 'root', parentLocationId: null }); + const childA = makeLocation({ id: 'childA', parentLocationId: 'root' }); + const childB = makeLocation({ id: 'childB', parentLocationId: 'root' }); + locationRepo.find.mockResolvedValue([root, childA, childB]); + + assetRepo.createQueryBuilder.mockReturnValue( + createMockQueryBuilder([ + { locationId: 'childA', count: '2' }, + { locationId: 'childB', count: '3' }, + ]), + ); + + const result = await service.findAll(); + const byId = new Map(result.map((l) => [l.id, l])); + + expect(byId.get('root')?.totalAssetCount).toBe(5); + }); + }); + + describe('findById', () => { + it('returns the location when found', async () => { + const loc = makeLocation(); + locationRepo.findOne.mockResolvedValue(loc); + + const result = await service.findById('loc-1'); + + expect(result).toBe(loc); + expect(locationRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'loc-1' }, + }); + }); + + it('throws NotFoundException when the location does not exist', async () => { + locationRepo.findOne.mockResolvedValue(null); + + await expect(service.findById('missing')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('create', () => { + it('creates a location with no parent', async () => { + const dto = { name: 'New Location' } as any; + const created = makeLocation({ id: 'new-loc', name: 'New Location' }); + locationRepo.create.mockReturnValue(created); + locationRepo.save.mockResolvedValue(created); + + const result = await service.create(dto); + + expect(locationRepo.findOne).not.toHaveBeenCalled(); + expect(locationRepo.create).toHaveBeenCalledWith(dto); + expect(locationRepo.save).toHaveBeenCalledWith(created); + expect(result).toBe(created); + }); + + it('validates the parent location exists before creating', async () => { + const dto = { name: 'Child', parentLocationId: 'parent-1' } as any; + const parent = makeLocation({ id: 'parent-1' }); + locationRepo.findOne.mockResolvedValue(parent); + const created = makeLocation({ + id: 'child-1', + parentLocationId: 'parent-1', + }); + locationRepo.create.mockReturnValue(created); + locationRepo.save.mockResolvedValue(created); + + await service.create(dto); + + expect(locationRepo.findOne).toHaveBeenCalledWith({ + where: { id: 'parent-1' }, + }); + }); + + it('throws NotFoundException when the given parent does not exist', async () => { + const dto = { name: 'Child', parentLocationId: 'missing-parent' } as any; + locationRepo.findOne.mockResolvedValue(null); + + await expect(service.create(dto)).rejects.toThrow(NotFoundException); + expect(locationRepo.save).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('updates fields and saves', async () => { + const loc = makeLocation({ id: 'loc-1', name: 'Old Name' }); + locationRepo.findOne.mockResolvedValue(loc); + locationRepo.save.mockImplementation((l) => Promise.resolve(l)); + + const result = await service.update('loc-1', { name: 'New Name' } as any); + + expect(result.name).toBe('New Name'); + expect(locationRepo.save).toHaveBeenCalled(); + }); + + it('throws BadRequestException when a location is set as its own parent', async () => { + const loc = makeLocation({ id: 'loc-1' }); + locationRepo.findOne.mockResolvedValue(loc); + + await expect( + service.update('loc-1', { parentLocationId: 'loc-1' } as any), + ).rejects.toThrow(BadRequestException); + expect(locationRepo.save).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException when moving a location under its own descendant', async () => { + // hierarchy: A -> B -> C. Trying to set A's parent to C (a descendant) must fail. + const locA = makeLocation({ id: 'A', parentLocationId: null }); + const locB = makeLocation({ id: 'B', parentLocationId: 'A' }); + const locC = makeLocation({ id: 'C', parentLocationId: 'B' }); + + // First findOne call (inside update) resolves the location being updated (A). + // Subsequent findOne calls walk up from candidate parent (C) via assertNotDescendant. + locationRepo.findOne + .mockResolvedValueOnce(locA) // findById('A') inside update() + .mockResolvedValueOnce(locC) // assertNotDescendant walk: current = 'C' + .mockResolvedValueOnce(locB); // assertNotDescendant walk: current = 'B' -> matches id 'A'? no, continues + // Note: walk stops as soon as current === id ('A'), which happens once we + // reach locB.parentLocationId === 'A'. We only need enough mocked calls to reach that point. + + await expect( + service.update('A', { parentLocationId: 'C' } as any), + ).rejects.toThrow(BadRequestException); + expect(locationRepo.save).not.toHaveBeenCalled(); + }); + + it('allows moving a location under an unrelated location', async () => { + const locA = makeLocation({ id: 'A', parentLocationId: null }); + const unrelated = makeLocation({ id: 'X', parentLocationId: null }); + + locationRepo.findOne + .mockResolvedValueOnce(locA) // findById('A') inside update() + .mockResolvedValueOnce(unrelated); // assertNotDescendant walk: current = 'X', no parent -> loop ends + locationRepo.save.mockImplementation((l) => Promise.resolve(l)); + + const result = await service.update('A', { + parentLocationId: 'X', + } as any); + + expect(result.parentLocationId).toBe('X'); + expect(locationRepo.save).toHaveBeenCalled(); + }); + + it('throws NotFoundException when updating a location that does not exist', async () => { + locationRepo.findOne.mockResolvedValue(null); + + await expect( + service.update('missing', { name: 'x' } as any), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('delete', () => { + it('removes a location with no children and no assets', async () => { + const loc = makeLocation({ id: 'loc-1' }); + locationRepo.findOne.mockResolvedValue(loc); + locationRepo.count.mockResolvedValue(0); + assetRepo.count.mockResolvedValue(0); + locationRepo.remove.mockResolvedValue(loc); + + const result = await service.delete('loc-1'); + + expect(locationRepo.remove).toHaveBeenCalledWith(loc); + expect(result).toBe(loc); + }); + + it('throws ConflictException when the location has child locations', async () => { + const loc = makeLocation({ id: 'loc-1' }); + locationRepo.findOne.mockResolvedValue(loc); + locationRepo.count.mockResolvedValue(2); + + await expect(service.delete('loc-1')).rejects.toThrow(ConflictException); + expect(locationRepo.remove).not.toHaveBeenCalled(); + }); + + it('throws ConflictException when the location still has assigned assets', async () => { + const loc = makeLocation({ id: 'loc-1' }); + locationRepo.findOne.mockResolvedValue(loc); + locationRepo.count.mockResolvedValue(0); // no child locations + assetRepo.count.mockResolvedValue(4); + + await expect(service.delete('loc-1')).rejects.toThrow(ConflictException); + expect(locationRepo.remove).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when deleting a location that does not exist', async () => { + locationRepo.findOne.mockResolvedValue(null); + + await expect(service.delete('missing')).rejects.toThrow( + NotFoundException, + ); + expect(locationRepo.remove).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 95f5641c..4ba8e71d 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -16,6 +16,7 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "types": ["node", "jest"] } }