diff --git a/maternal-app/maternal-app-backend/src/modules/photos/photos.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/photos/photos.service.spec.ts new file mode 100644 index 0000000..7349558 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/photos/photos.service.spec.ts @@ -0,0 +1,506 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { PhotosService, UploadPhotoDto } from './photos.service'; +import { Photo, PhotoType } from '../../database/entities/photo.entity'; +import { StorageService } from '../../common/services/storage.service'; +import { AuditService } from '../../common/services/audit.service'; + +describe('PhotosService', () => { + let service: PhotosService; + let photoRepository: Repository; + let storageService: StorageService; + let auditService: AuditService; + + const mockPhoto = { + id: 'photo_123', + userId: 'user_123', + childId: 'child_123', + activityId: null, + type: PhotoType.GENERAL, + originalFilename: 'baby_smile.jpg', + mimeType: 'image/jpeg', + fileSize: 1024000, + storageKey: 'photos/user_123/child_123/abc123.jpg', + thumbnailKey: 'photos/user_123/child_123/thumbnails/abc123_thumb.jpg', + width: 1920, + height: 1080, + caption: 'Beautiful smile!', + description: null, + takenAt: new Date('2025-01-01'), + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockFile: Express.Multer.File = { + fieldname: 'photo', + originalname: 'test.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + size: 1024000, + buffer: Buffer.from('fake-image-data'), + stream: null as any, + destination: '', + filename: '', + path: '', + }; + + const mockPhotoRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockStorageService = { + uploadImage: jest.fn().mockResolvedValue({ + key: 'photos/user_123/child_123/abc123.jpg', + url: 'https://storage.com/photo.jpg', + metadata: { + size: 1024000, + width: 1920, + height: 1080, + }, + }), + generateThumbnail: jest.fn().mockResolvedValue({ + key: 'photos/user_123/child_123/thumbnails/abc123_thumb.jpg', + url: 'https://storage.com/thumb.jpg', + }), + getPresignedUrl: jest + .fn() + .mockResolvedValue('https://storage.com/presigned/photo.jpg'), + deleteFile: jest.fn().mockResolvedValue(true), + }; + + const mockAuditService = { + logCreate: jest.fn().mockResolvedValue(undefined), + logDelete: jest.fn().mockResolvedValue(undefined), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PhotosService, + { + provide: getRepositoryToken(Photo), + useValue: mockPhotoRepository, + }, + { + provide: StorageService, + useValue: mockStorageService, + }, + { + provide: AuditService, + useValue: mockAuditService, + }, + ], + }).compile(); + + service = module.get(PhotosService); + photoRepository = module.get>( + getRepositoryToken(Photo), + ); + storageService = module.get(StorageService); + auditService = module.get(AuditService); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('uploadPhoto', () => { + it('should upload photo with thumbnail generation', async () => { + const uploadDto: UploadPhotoDto = { + userId: 'user_123', + childId: 'child_123', + type: PhotoType.GENERAL, + caption: 'Test photo', + }; + + mockPhotoRepository.create.mockReturnValue(mockPhoto); + mockPhotoRepository.save.mockResolvedValue(mockPhoto); + + const result = await service.uploadPhoto(mockFile, uploadDto); + + expect(storageService.uploadImage).toHaveBeenCalledWith( + mockFile.buffer, + expect.stringContaining('photos/user_123/child_123'), + { + maxWidth: 1920, + maxHeight: 1920, + quality: 85, + }, + ); + expect(storageService.generateThumbnail).toHaveBeenCalledWith( + mockFile.buffer, + expect.stringContaining('thumbnails'), + 300, + 300, + ); + expect(photoRepository.save).toHaveBeenCalled(); + expect(auditService.logCreate).toHaveBeenCalled(); + expect(result).toEqual(mockPhoto); + }); + + it('should handle photo upload without childId', async () => { + const uploadDto: UploadPhotoDto = { + userId: 'user_123', + type: PhotoType.GENERAL, + }; + + mockPhotoRepository.create.mockReturnValue(mockPhoto); + mockPhotoRepository.save.mockResolvedValue(mockPhoto); + + await service.uploadPhoto(mockFile, uploadDto); + + expect(storageService.uploadImage).toHaveBeenCalledWith( + mockFile.buffer, + expect.stringContaining('general'), + expect.any(Object), + ); + }); + + it('should throw error if upload fails', async () => { + mockStorageService.uploadImage.mockRejectedValue( + new Error('Storage error'), + ); + + const uploadDto: UploadPhotoDto = { + userId: 'user_123', + type: PhotoType.GENERAL, + }; + + await expect(service.uploadPhoto(mockFile, uploadDto)).rejects.toThrow( + 'Photo upload failed', + ); + }); + + it('should include optional metadata', async () => { + const uploadDto: UploadPhotoDto = { + userId: 'user_123', + childId: 'child_123', + type: PhotoType.MILESTONE, + caption: 'First steps', + description: 'Baby walked for the first time!', + takenAt: new Date('2025-01-01'), + metadata: { milestone: 'first_steps', ageMonths: 12 }, + }; + + mockPhotoRepository.create.mockReturnValue(mockPhoto); + mockPhotoRepository.save.mockResolvedValue(mockPhoto); + + await service.uploadPhoto(mockFile, uploadDto); + + expect(photoRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + caption: 'First steps', + description: 'Baby walked for the first time!', + takenAt: uploadDto.takenAt, + metadata: uploadDto.metadata, + }), + ); + }); + }); + + describe('getChildPhotos', () => { + it('should get all photos for a child', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(5), + getMany: jest.fn().mockResolvedValue([mockPhoto, mockPhoto]), + }; + + mockPhotoRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getChildPhotos('child_123'); + + expect(result.photos).toHaveLength(2); + expect(result.total).toBe(5); + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'photo.childId = :childId', + { childId: 'child_123' }, + ); + }); + + it('should filter by photo type', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(3), + getMany: jest.fn().mockResolvedValue([mockPhoto]), + }; + + mockPhotoRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + await service.getChildPhotos('child_123', { + type: PhotoType.MILESTONE, + }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'photo.type = :type', + { type: PhotoType.MILESTONE }, + ); + }); + + it('should apply limit and offset', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(100), + getMany: jest.fn().mockResolvedValue([]), + }; + + mockPhotoRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + await service.getChildPhotos('child_123', { + limit: 10, + offset: 20, + }); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20); + }); + }); + + describe('getActivityPhotos', () => { + it('should get photos for an activity', async () => { + mockPhotoRepository.find.mockResolvedValue([mockPhoto]); + + const result = await service.getActivityPhotos('activity_123'); + + expect(photoRepository.find).toHaveBeenCalledWith({ + where: { activityId: 'activity_123' }, + order: { createdAt: 'ASC' }, + }); + expect(result).toEqual([mockPhoto]); + }); + }); + + describe('getPhoto', () => { + it('should get photo by ID', async () => { + mockPhotoRepository.findOne.mockResolvedValue(mockPhoto); + + const result = await service.getPhoto('photo_123', 'user_123'); + + expect(photoRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'photo_123', userId: 'user_123' }, + relations: ['child', 'activity'], + }); + expect(result).toEqual(mockPhoto); + }); + + it('should throw NotFoundException if photo not found', async () => { + mockPhotoRepository.findOne.mockResolvedValue(null); + + await expect(service.getPhoto('photo_123', 'user_123')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('getPhotoWithUrl', () => { + it('should return photo with presigned URLs', async () => { + mockPhotoRepository.findOne.mockResolvedValue(mockPhoto); + + const result = await service.getPhotoWithUrl('photo_123', 'user_123'); + + expect(storageService.getPresignedUrl).toHaveBeenCalledWith( + mockPhoto.storageKey, + 3600, + ); + expect(storageService.getPresignedUrl).toHaveBeenCalledWith( + mockPhoto.thumbnailKey, + 3600, + ); + expect(result).toHaveProperty('url'); + expect(result).toHaveProperty('thumbnailUrl'); + }); + + it('should use main URL for thumbnail if no thumbnail key', async () => { + const photoWithoutThumbnail = { ...mockPhoto, thumbnailKey: null }; + mockPhotoRepository.findOne.mockResolvedValue(photoWithoutThumbnail); + + const result = await service.getPhotoWithUrl('photo_123', 'user_123'); + + expect(result.url).toBe(result.thumbnailUrl); + }); + }); + + describe('getGallery', () => { + it('should get photos with presigned URLs', async () => { + const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(2), + getMany: jest.fn().mockResolvedValue([mockPhoto, mockPhoto]), + }; + + mockPhotoRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await service.getGallery('child_123', 'user_123'); + + expect(result.total).toBe(2); + expect(result.photos).toHaveLength(2); + expect(result.photos[0]).toHaveProperty('url'); + expect(result.photos[0]).toHaveProperty('thumbnailUrl'); + }); + }); + + describe('updatePhoto', () => { + it('should update photo metadata', async () => { + mockPhotoRepository.findOne.mockResolvedValue(mockPhoto); + const updatedPhoto = { ...mockPhoto, caption: 'Updated caption' }; + mockPhotoRepository.save.mockResolvedValue(updatedPhoto); + + const result = await service.updatePhoto('photo_123', 'user_123', { + caption: 'Updated caption', + description: 'New description', + }); + + expect(photoRepository.save).toHaveBeenCalled(); + expect(result.caption).toBe('Updated caption'); + }); + + it('should throw error if photo not found', async () => { + mockPhotoRepository.findOne.mockResolvedValue(null); + + await expect( + service.updatePhoto('photo_123', 'user_123', { caption: 'Test' }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('deletePhoto', () => { + it('should delete photo from storage and database', async () => { + mockPhotoRepository.findOne.mockResolvedValue(mockPhoto); + mockPhotoRepository.remove.mockResolvedValue(mockPhoto); + + await service.deletePhoto('photo_123', 'user_123'); + + expect(storageService.deleteFile).toHaveBeenCalledWith( + mockPhoto.storageKey, + ); + expect(storageService.deleteFile).toHaveBeenCalledWith( + mockPhoto.thumbnailKey, + ); + expect(photoRepository.remove).toHaveBeenCalledWith(mockPhoto); + expect(auditService.logDelete).toHaveBeenCalled(); + }); + + it('should continue with database deletion if storage deletion fails', async () => { + mockPhotoRepository.findOne.mockResolvedValue(mockPhoto); + mockPhotoRepository.remove.mockResolvedValue(mockPhoto); + mockStorageService.deleteFile.mockRejectedValue( + new Error('Storage error'), + ); + + await service.deletePhoto('photo_123', 'user_123'); + + expect(photoRepository.remove).toHaveBeenCalledWith(mockPhoto); + }); + + it('should throw error if photo not found', async () => { + mockPhotoRepository.findOne.mockResolvedValue(null); + + await expect( + service.deletePhoto('photo_123', 'user_123'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('getMilestonePhotos', () => { + it('should get milestone photos ordered by takenAt', async () => { + const milestonePhotos = [ + { ...mockPhoto, type: PhotoType.MILESTONE }, + { ...mockPhoto, type: PhotoType.MILESTONE }, + ]; + mockPhotoRepository.find.mockResolvedValue(milestonePhotos); + + const result = await service.getMilestonePhotos('child_123'); + + expect(photoRepository.find).toHaveBeenCalledWith({ + where: { childId: 'child_123', type: PhotoType.MILESTONE }, + order: { takenAt: 'ASC', createdAt: 'ASC' }, + }); + expect(result).toEqual(milestonePhotos); + }); + }); + + describe('getRecentPhotos', () => { + it('should get recent photos with default limit', async () => { + mockPhotoRepository.find.mockResolvedValue([mockPhoto]); + + const result = await service.getRecentPhotos('user_123'); + + expect(photoRepository.find).toHaveBeenCalledWith({ + where: { userId: 'user_123' }, + order: { createdAt: 'DESC' }, + take: 10, + relations: ['child'], + }); + expect(result).toEqual([mockPhoto]); + }); + + it('should apply custom limit', async () => { + mockPhotoRepository.find.mockResolvedValue([]); + + await service.getRecentPhotos('user_123', 5); + + expect(photoRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + take: 5, + }), + ); + }); + }); + + describe('getPhotoStats', () => { + it('should calculate photo statistics', async () => { + const photos = [ + { ...mockPhoto, type: PhotoType.MILESTONE, fileSize: 500000 }, + { ...mockPhoto, type: PhotoType.MILESTONE, fileSize: 600000 }, + { ...mockPhoto, type: PhotoType.ACTIVITY, fileSize: 400000 }, + { ...mockPhoto, type: PhotoType.GENERAL, fileSize: 300000 }, + ]; + mockPhotoRepository.find.mockResolvedValue(photos); + + const result = await service.getPhotoStats('child_123'); + + expect(result.total).toBe(4); + expect(result.byType[PhotoType.MILESTONE]).toBe(2); + expect(result.byType[PhotoType.ACTIVITY]).toBe(1); + expect(result.byType[PhotoType.GENERAL]).toBe(1); + expect(result.byType[PhotoType.PROFILE]).toBe(0); + expect(result.totalSize).toBe(1800000); + }); + + it('should handle empty photo collection', async () => { + mockPhotoRepository.find.mockResolvedValue([]); + + const result = await service.getPhotoStats('child_123'); + + expect(result.total).toBe(0); + expect(result.totalSize).toBe(0); + expect(result.byType[PhotoType.MILESTONE]).toBe(0); + }); + }); +});