test: Add Photos service tests (506 lines, 24 tests)
Created comprehensive test suite for photo management service: - Upload photo with thumbnail generation and optimization - File storage integration (original + thumbnail) - Get photos by child/activity with filtering and pagination - Photo metadata management (caption, description, type) - Presigned URL generation for secure downloads - Gallery view with URLs - Update photo metadata - Delete photo from storage and database - Milestone photo tracking - Recent photos with child relations - Photo statistics (total, by type, file size) Tests cover: - Success cases with storage service integration - Error handling (not found, upload failures, storage errors) - Edge cases (no thumbnail, empty collections) - Filtering (by type, limit, offset) - Audit logging integration Total: 506 lines, 24 test cases Coverage: Photo upload, gallery, CRUD, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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<Photo>;
|
||||
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>(PhotosService);
|
||||
photoRepository = module.get<Repository<Photo>>(
|
||||
getRepositoryToken(Photo),
|
||||
);
|
||||
storageService = module.get<StorageService>(StorageService);
|
||||
auditService = module.get<AuditService>(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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user