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:
2025-10-02 19:56:23 +00:00
parent b99ee519d6
commit b089b69b59

View File

@@ -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);
});
});
});